commit 6fdd9b4d7a90026db5797f3bdde1b8184fd0afb9 Author: wehub-resource-sync Date: Mon Jul 13 12:48:46 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..f0bcdbe --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [simonw] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3760d92 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: daily + groups: + python-packages: + patterns: + - "*" diff --git a/.github/workflows/cog.yml b/.github/workflows/cog.yml new file mode 100644 index 0000000..6ff5df5 --- /dev/null +++ b/.github/workflows/cog.yml @@ -0,0 +1,49 @@ +name: Run Cog + +on: + pull_request: + types: [opened, synchronize] + +permissions: + contents: write + pull-requests: write + +jobs: + run-cog: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + + - name: Set up Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install . --group dev + + - name: Run cog + run: | + cog -r -p "import sys, os; sys._called_from_test=True; os.environ['LLM_USER_PATH'] = '/tmp'" docs/**/*.md docs/*.md README.md + + - name: Check for changes + id: check-changes + run: | + if [ -n "$(git diff)" ]; then + echo "changes=true" >> $GITHUB_OUTPUT + else + echo "changes=false" >> $GITHUB_OUTPUT + fi + + - name: Commit and push if changed + if: steps.check-changes.outputs.changes == 'true' + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add -A + git commit -m "Ran cog" + git push diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..40e4cee --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,51 @@ +name: Publish Python Package + +on: + release: + types: [created] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v6 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + - name: Install dependencies + run: | + pip install . --group dev + - name: Run tests + run: | + pytest + deploy: + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + needs: [test] + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + cache: pip + cache-dependency-path: pyproject.toml + - name: Install dependencies + run: | + pip install setuptools wheel build + - name: Build + run: | + python -m build + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/stable-docs.yml b/.github/workflows/stable-docs.yml new file mode 100644 index 0000000..f55ac85 --- /dev/null +++ b/.github/workflows/stable-docs.yml @@ -0,0 +1,64 @@ +name: Update Stable Docs + +on: + release: + types: [published] + push: + branches: + - main + +permissions: + contents: write + +jobs: + update_stable_docs: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 # We need all commits to find docs/ changes + - name: Set up Git user + run: | + git config user.name "Automated" + git config user.email "actions@users.noreply.github.com" + - name: Create stable branch if it does not yet exist + run: | + if ! git ls-remote --heads origin stable | grep stable; then + git checkout -b stable + # If there are any releases, copy docs/ in from most recent + LATEST_RELEASE=$(git tag | sort -Vr | head -n1) + if [ -n "$LATEST_RELEASE" ]; then + rm -rf docs/ + git checkout $LATEST_RELEASE -- docs/ + fi + git commit -m "Populate docs/ from $LATEST_RELEASE" || echo "No changes" + git push -u origin stable + fi + - name: Handle Release + if: github.event_name == 'release' && !github.event.release.prerelease + run: | + git fetch --all + git checkout stable + git reset --hard ${GITHUB_REF#refs/tags/} + git push origin stable --force + - name: Handle Commit to Main + if: contains(github.event.head_commit.message, '!stable-docs') + run: | + git fetch origin + git checkout -b stable origin/stable + # Get the list of modified files in docs/ from the current commit + FILES=$(git diff-tree --no-commit-id --name-only -r ${{ github.sha }} -- docs/) + # Check if the list of files is non-empty + if [[ -n "$FILES" ]]; then + # Checkout those files to the stable branch to over-write with their contents + for FILE in $FILES; do + git checkout ${{ github.sha }} -- $FILE + done + git add docs/ + git commit -m "Doc changes from ${{ github.sha }}" + git push origin stable + else + echo "No changes to docs/ in this commit." + exit 0 + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..38e0da7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,69 @@ +name: Test + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + sqlite-utils-version: [""] + include: + - os: ubuntu-latest + python-version: "3.14" + sqlite-utils-version: "4.0rc4" + steps: + - uses: actions/checkout@v6 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + - name: Install dependencies + run: | + pip install . --group dev + - name: Install sqlite-utils pre-release + if: matrix.sqlite-utils-version != '' + run: | + pip install sqlite-utils==${{ matrix.sqlite-utils-version }} + - name: Run tests + run: | + python -m pytest -vv + - name: Check if cog needs to be run + if: matrix.sqlite-utils-version == '4.0rc4' + run: | + cog --check \ + -p "import sys, os; sys._called_from_test=True; os.environ['LLM_USER_PATH'] = '/tmp'" \ + docs/**/*.md docs/*.md + - name: Run Black + if: matrix.os != 'windows-latest' + run: | + black --check . + - name: Run mypy + if: matrix.os != 'windows-latest' + run: | + mypy llm + - name: Run ruff + if: matrix.os != 'windows-latest' + run: | + ruff check . + - name: Check it builds + run: | + python -m build + - name: Run test-llm-load-plugins.sh + if: matrix.os != 'windows-latest' + run: | + llm install llm-cluster llm-mistral + ./tests/test-llm-load-plugins.sh + - name: Upload artifact of builds + if: matrix.python-version == '3.13' && matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v7 + with: + name: dist-${{ matrix.os }}-${{ matrix.python-version }} + path: dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..301f90d --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.venv +__pycache__/ +*.py[cod] +*$py.class +venv +.eggs +.pytest_cache +*.egg-info +build/ +.DS_Store +.idea/ +.vscode/ +uv.lock +*.db diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..6af5cc8 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,19 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +sphinx: + configuration: docs/conf.py + +formats: + - pdf + - epub + +python: + install: + - requirements: docs/requirements.txt + - method: pip + path: . diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4a31f46 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# AGENTS.md + +This project uses a Python environment for development and tests. + +## Setting up a development environment + +1. Install the project with its test dependencies: + ```bash + pip install -e '.[test]' + ``` +2. Run the tests: + ```bash + pytest + ``` + +## Building the documentation + +Run the following commands if you want to build the docs locally: + +```bash +cd docs +pip install -r requirements.txt +make html +``` diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..c74742b --- /dev/null +++ b/Justfile @@ -0,0 +1,46 @@ +# Run tests and linters +@default: test lint + +# Run pytest with supplied options +@test *options: + uv run pytest {{options}} + +# Run linters +@lint: + echo "Linters..." + echo " Black" + uv run black . --check + echo " cog" + uv run --with sqlite-utils==4.0rc2 cog --check \ + -p "import sys, os; sys._called_from_test=True; os.environ['LLM_USER_PATH'] = '/tmp'" \ + README.md docs/*.md + echo " mypy" + uv run mypy llm + echo " ruff" + uv run ruff check . + +# Run mypy +@mypy: + uv run mypy llm + +# Rebuild docs with cog +@cog: + uv run --with sqlite-utils==4.0rc2 cog -r -p "import sys, os; sys._called_from_test=True; os.environ['LLM_USER_PATH'] = '/tmp'" docs/**/*.md docs/*.md README.md + +# Serve live docs on localhost:8000 +@docs: cog + rm -rf docs/_build + cd docs && uv run make livehtml + +# Apply Black +@black: + uv run black . + +# Run automatic fixes +@fix: cog + uv run ruff check . --fix + uv run black . + +# Push commit if tests pass +@push: test lint + git push diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f408970 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +global-exclude tests/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3da244 --- /dev/null +++ b/README.md @@ -0,0 +1,368 @@ + +# LLM + +[![GitHub repo](https://img.shields.io/badge/github-repo-green)](https://github.com/simonw/llm) +[![PyPI](https://img.shields.io/pypi/v/llm.svg)](https://pypi.org/project/llm/) +[![Changelog](https://img.shields.io/github/v/release/simonw/llm?include_prereleases&label=changelog)](https://llm.datasette.io/en/stable/changelog.html) +[![Tests](https://github.com/simonw/llm/workflows/Test/badge.svg)](https://github.com/simonw/llm/actions?query=workflow%3ATest) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/llm/blob/main/LICENSE) +[![Discord](https://img.shields.io/discord/823971286308356157?label=discord)](https://datasette.io/discord-llm) +[![Homebrew](https://img.shields.io/homebrew/installs/dy/llm?color=yellow&label=homebrew&logo=homebrew)](https://formulae.brew.sh/formula/llm) + +A CLI tool and Python library for interacting with **OpenAI**, **Anthropic’s Claude**, **Google’s Gemini**, **Meta’s Llama** and dozens of other Large Language Models, both via remote APIs and with models that can be installed and run on your own machine. + +Watch **[Language models on the command-line](https://www.youtube.com/watch?v=QUXQNi6jQ30)** on YouTube for a demo or [read the accompanying detailed notes](https://simonwillison.net/2024/Jun/17/cli-language-models/). + +With LLM you can: + +- [Run prompts from the command-line](https://llm.datasette.io/en/stable/usage.html#usage-executing-prompts) +- [Store prompts and responses in SQLite](https://llm.datasette.io/en/stable/logging.html#logging) +- [Generate and store embeddings](https://llm.datasette.io/en/stable/embeddings/index.html#embeddings) +- [Extract structured content from text and images](https://llm.datasette.io/en/stable/schemas.html#schemas) +- [Grant models the ability to execute tools](https://llm.datasette.io/en/stable/tools.html#tools) +- … and much, much more + +## Quick start + +First, install LLM using `pip` or Homebrew or `pipx` or `uv`: + +```bash +pip install llm +``` + +Or with Homebrew (see [warning note](https://llm.datasette.io/en/stable/setup.html#homebrew-warning)): + +```bash +brew install llm +``` + +Or with [pipx](https://pypa.github.io/pipx/): + +```bash +pipx install llm +``` + +Or with [uv](https://docs.astral.sh/uv/guides/tools/) + +```bash +uv tool install llm +``` + +If you have an [OpenAI API key](https://platform.openai.com/api-keys) key you can run this: + +```bash +# Paste your OpenAI API key into this +llm keys set openai + +# Run a prompt (with the default gpt-4o-mini model) +llm "Ten fun names for a pet pelican" + +# Extract text from an image +llm "extract text" -a scanned-document.jpg + +# Use a system prompt against a file +cat myfile.py | llm -s "Explain this code" +``` + +Run prompts against [Gemini](https://aistudio.google.com/apikey) or [Anthropic](https://console.anthropic.com/) with their respective plugins: + +```bash +llm install llm-gemini +llm keys set gemini +# Paste Gemini API key here +llm -m gemini-2.0-flash 'Tell me fun facts about Mountain View' + +llm install llm-anthropic +llm keys set anthropic +# Paste Anthropic API key here +llm -m claude-4-opus 'Impress me with wild facts about turnips' +``` + +You can also [install a plugin](https://llm.datasette.io/en/stable/plugins/installing-plugins.html#installing-plugins) to access models that can run on your local device. If you use [Ollama](https://ollama.com/): + +```bash +# Install the plugin +llm install llm-ollama + +# Download and run a prompt against the Orca Mini 7B model +ollama pull llama3.2:latest +llm -m llama3.2:latest 'What is the capital of France?' +``` + +To start [an interactive chat](https://llm.datasette.io/en/stable/usage.html#usage-chat) with a model, use `llm chat`: + +```bash +llm chat -m gpt-4.1 +``` + +```default +Chatting with gpt-4.1 +Type 'exit' or 'quit' to exit +Type '!multi' to enter multiple lines, then '!end' to finish +Type '!edit' to open your default editor and modify the prompt. +Type '!fragment [ ...]' to insert one or more fragments +> Tell me a joke about a pelican +Why don't pelicans like to tip waiters? + +Because they always have a big bill! +``` + +More background on this project: + +- [llm, ttok and strip-tags—CLI tools for working with ChatGPT and other LLMs](https://simonwillison.net/2023/May/18/cli-tools-for-llms/) +- [The LLM CLI tool now supports self-hosted language models via plugins](https://simonwillison.net/2023/Jul/12/llm/) +- [LLM now provides tools for working with embeddings](https://simonwillison.net/2023/Sep/4/llm-embeddings/) +- [Build an image search engine with llm-clip, chat with models with llm chat](https://simonwillison.net/2023/Sep/12/llm-clip-and-chat/) +- [You can now run prompts against images, audio and video in your terminal using LLM](https://simonwillison.net/2024/Oct/29/llm-multi-modal/) +- [Structured data extraction from unstructured content using LLM schemas](https://simonwillison.net/2025/Feb/28/llm-schemas/) +- [Long context support in LLM 0.24 using fragments and template plugins](https://simonwillison.net/2025/Apr/7/long-context-llm/) + +See also [the llm tag](https://simonwillison.net/tags/llm/) on my blog. + +## Contents + +* [Setup](https://llm.datasette.io/en/stable/setup.html) + * [Installation](https://llm.datasette.io/en/stable/setup.html#installation) + * [Upgrading to the latest version](https://llm.datasette.io/en/stable/setup.html#upgrading-to-the-latest-version) + * [Using uvx](https://llm.datasette.io/en/stable/setup.html#using-uvx) + * [A note about Homebrew and PyTorch](https://llm.datasette.io/en/stable/setup.html#a-note-about-homebrew-and-pytorch) + * [Installing plugins](https://llm.datasette.io/en/stable/setup.html#installing-plugins) + * [API key management](https://llm.datasette.io/en/stable/setup.html#api-key-management) + * [Saving and using stored keys](https://llm.datasette.io/en/stable/setup.html#saving-and-using-stored-keys) + * [Passing keys using the –key option](https://llm.datasette.io/en/stable/setup.html#passing-keys-using-the-key-option) + * [Keys in environment variables](https://llm.datasette.io/en/stable/setup.html#keys-in-environment-variables) + * [Configuration](https://llm.datasette.io/en/stable/setup.html#configuration) + * [Setting a custom default model](https://llm.datasette.io/en/stable/setup.html#setting-a-custom-default-model) + * [Setting a custom directory location](https://llm.datasette.io/en/stable/setup.html#setting-a-custom-directory-location) + * [Turning SQLite logging on and off](https://llm.datasette.io/en/stable/setup.html#turning-sqlite-logging-on-and-off) +* [Usage](https://llm.datasette.io/en/stable/usage.html) + * [Executing a prompt](https://llm.datasette.io/en/stable/usage.html#executing-a-prompt) + * [Model options](https://llm.datasette.io/en/stable/usage.html#model-options) + * [Attachments](https://llm.datasette.io/en/stable/usage.html#attachments) + * [System prompts](https://llm.datasette.io/en/stable/usage.html#system-prompts) + * [Tools](https://llm.datasette.io/en/stable/usage.html#tools) + * [Extracting fenced code blocks](https://llm.datasette.io/en/stable/usage.html#extracting-fenced-code-blocks) + * [Schemas](https://llm.datasette.io/en/stable/usage.html#schemas) + * [Fragments](https://llm.datasette.io/en/stable/usage.html#fragments) + * [Continuing a conversation](https://llm.datasette.io/en/stable/usage.html#continuing-a-conversation) + * [Tips for using LLM with Bash or Zsh](https://llm.datasette.io/en/stable/usage.html#tips-for-using-llm-with-bash-or-zsh) + * [Completion prompts](https://llm.datasette.io/en/stable/usage.html#completion-prompts) + * [Starting an interactive chat](https://llm.datasette.io/en/stable/usage.html#starting-an-interactive-chat) + * [Listing available models](https://llm.datasette.io/en/stable/usage.html#listing-available-models) + * [Setting default options for models](https://llm.datasette.io/en/stable/usage.html#setting-default-options-for-models) +* [OpenAI models](https://llm.datasette.io/en/stable/openai-models.html) + * [Configuration](https://llm.datasette.io/en/stable/openai-models.html#configuration) + * [OpenAI language models](https://llm.datasette.io/en/stable/openai-models.html#openai-language-models) + * [Model features](https://llm.datasette.io/en/stable/openai-models.html#model-features) + * [OpenAI embedding models](https://llm.datasette.io/en/stable/openai-models.html#openai-embedding-models) + * [OpenAI completion models](https://llm.datasette.io/en/stable/openai-models.html#openai-completion-models) + * [Adding more OpenAI models](https://llm.datasette.io/en/stable/openai-models.html#adding-more-openai-models) +* [Other models](https://llm.datasette.io/en/stable/other-models.html) + * [Installing and using a local model](https://llm.datasette.io/en/stable/other-models.html#installing-and-using-a-local-model) + * [OpenAI-compatible models](https://llm.datasette.io/en/stable/other-models.html#openai-compatible-models) + * [Extra HTTP headers](https://llm.datasette.io/en/stable/other-models.html#extra-http-headers) +* [Tools](https://llm.datasette.io/en/stable/tools.html) + * [How tools work](https://llm.datasette.io/en/stable/tools.html#how-tools-work) + * [Trying out tools](https://llm.datasette.io/en/stable/tools.html#trying-out-tools) + * [LLM’s implementation of tools](https://llm.datasette.io/en/stable/tools.html#llm-s-implementation-of-tools) + * [Default tools](https://llm.datasette.io/en/stable/tools.html#default-tools) + * [Tips for implementing tools](https://llm.datasette.io/en/stable/tools.html#tips-for-implementing-tools) +* [Schemas](https://llm.datasette.io/en/stable/schemas.html) + * [Schemas tutorial](https://llm.datasette.io/en/stable/schemas.html#schemas-tutorial) + * [Getting started with dogs](https://llm.datasette.io/en/stable/schemas.html#getting-started-with-dogs) + * [Extracting people from a news articles](https://llm.datasette.io/en/stable/schemas.html#extracting-people-from-a-news-articles) + * [Using JSON schemas](https://llm.datasette.io/en/stable/schemas.html#using-json-schemas) + * [Ways to specify a schema](https://llm.datasette.io/en/stable/schemas.html#ways-to-specify-a-schema) + * [Concise LLM schema syntax](https://llm.datasette.io/en/stable/schemas.html#concise-llm-schema-syntax) + * [Saving reusable schemas in templates](https://llm.datasette.io/en/stable/schemas.html#saving-reusable-schemas-in-templates) + * [Browsing logged JSON objects created using schemas](https://llm.datasette.io/en/stable/schemas.html#browsing-logged-json-objects-created-using-schemas) +* [Templates](https://llm.datasette.io/en/stable/templates.html) + * [Getting started with –save](https://llm.datasette.io/en/stable/templates.html#getting-started-with-save) + * [Using a template](https://llm.datasette.io/en/stable/templates.html#using-a-template) + * [Listing available templates](https://llm.datasette.io/en/stable/templates.html#listing-available-templates) + * [Templates as YAML files](https://llm.datasette.io/en/stable/templates.html#templates-as-yaml-files) + * [System prompts](https://llm.datasette.io/en/stable/templates.html#system-prompts) + * [Fragments](https://llm.datasette.io/en/stable/templates.html#fragments) + * [Options](https://llm.datasette.io/en/stable/templates.html#options) + * [Tools](https://llm.datasette.io/en/stable/templates.html#tools) + * [Schemas](https://llm.datasette.io/en/stable/templates.html#schemas) + * [Additional template variables](https://llm.datasette.io/en/stable/templates.html#additional-template-variables) + * [Specifying default parameters](https://llm.datasette.io/en/stable/templates.html#specifying-default-parameters) + * [Configuring code extraction](https://llm.datasette.io/en/stable/templates.html#configuring-code-extraction) + * [Setting a default model for a template](https://llm.datasette.io/en/stable/templates.html#setting-a-default-model-for-a-template) + * [Template loaders from plugins](https://llm.datasette.io/en/stable/templates.html#template-loaders-from-plugins) +* [Fragments](https://llm.datasette.io/en/stable/fragments.html) + * [Using fragments in a prompt](https://llm.datasette.io/en/stable/fragments.html#using-fragments-in-a-prompt) + * [Using fragments in chat](https://llm.datasette.io/en/stable/fragments.html#using-fragments-in-chat) + * [Browsing fragments](https://llm.datasette.io/en/stable/fragments.html#browsing-fragments) + * [Setting aliases for fragments](https://llm.datasette.io/en/stable/fragments.html#setting-aliases-for-fragments) + * [Viewing fragments in your logs](https://llm.datasette.io/en/stable/fragments.html#viewing-fragments-in-your-logs) + * [Using fragments from plugins](https://llm.datasette.io/en/stable/fragments.html#using-fragments-from-plugins) + * [Listing available fragment prefixes](https://llm.datasette.io/en/stable/fragments.html#listing-available-fragment-prefixes) +* [Model aliases](https://llm.datasette.io/en/stable/aliases.html) + * [Listing aliases](https://llm.datasette.io/en/stable/aliases.html#listing-aliases) + * [Adding a new alias](https://llm.datasette.io/en/stable/aliases.html#adding-a-new-alias) + * [Removing an alias](https://llm.datasette.io/en/stable/aliases.html#removing-an-alias) + * [Viewing the aliases file](https://llm.datasette.io/en/stable/aliases.html#viewing-the-aliases-file) +* [Embeddings](https://llm.datasette.io/en/stable/embeddings/index.html) + * [Embedding with the CLI](https://llm.datasette.io/en/stable/embeddings/cli.html) + * [llm embed](https://llm.datasette.io/en/stable/embeddings/cli.html#llm-embed) + * [llm embed-multi](https://llm.datasette.io/en/stable/embeddings/cli.html#llm-embed-multi) + * [llm similar](https://llm.datasette.io/en/stable/embeddings/cli.html#llm-similar) + * [llm embed-models](https://llm.datasette.io/en/stable/embeddings/cli.html#llm-embed-models) + * [llm collections list](https://llm.datasette.io/en/stable/embeddings/cli.html#llm-collections-list) + * [llm collections delete](https://llm.datasette.io/en/stable/embeddings/cli.html#llm-collections-delete) + * [Using embeddings from Python](https://llm.datasette.io/en/stable/embeddings/python-api.html) + * [Working with collections](https://llm.datasette.io/en/stable/embeddings/python-api.html#working-with-collections) + * [Retrieving similar items](https://llm.datasette.io/en/stable/embeddings/python-api.html#retrieving-similar-items) + * [SQL schema](https://llm.datasette.io/en/stable/embeddings/python-api.html#sql-schema) + * [Writing plugins to add new embedding models](https://llm.datasette.io/en/stable/embeddings/writing-plugins.html) + * [`EmbeddingModel`](https://llm.datasette.io/en/stable/embeddings/writing-plugins.html#llm.EmbeddingModel) + * [Embedding binary content](https://llm.datasette.io/en/stable/embeddings/writing-plugins.html#embedding-binary-content) + * [Embedding storage format](https://llm.datasette.io/en/stable/embeddings/storage.html) +* [Plugins](https://llm.datasette.io/en/stable/plugins/index.html) + * [Installing plugins](https://llm.datasette.io/en/stable/plugins/installing-plugins.html) + * [Listing installed plugins](https://llm.datasette.io/en/stable/plugins/installing-plugins.html#listing-installed-plugins) + * [Running with a subset of plugins](https://llm.datasette.io/en/stable/plugins/installing-plugins.html#running-with-a-subset-of-plugins) + * [Plugin directory](https://llm.datasette.io/en/stable/plugins/directory.html) + * [Local models](https://llm.datasette.io/en/stable/plugins/directory.html#local-models) + * [Remote APIs](https://llm.datasette.io/en/stable/plugins/directory.html#remote-apis) + * [Tools](https://llm.datasette.io/en/stable/plugins/directory.html#tools) + * [Fragments and template loaders](https://llm.datasette.io/en/stable/plugins/directory.html#fragments-and-template-loaders) + * [Embedding models](https://llm.datasette.io/en/stable/plugins/directory.html#embedding-models) + * [Extra commands](https://llm.datasette.io/en/stable/plugins/directory.html#extra-commands) + * [Just for fun](https://llm.datasette.io/en/stable/plugins/directory.html#just-for-fun) + * [Plugin hooks](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html) + * [register_commands(cli)](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-commands-cli) + * [register_models(register, model_aliases)](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-models-register-model-aliases) + * [register_embedding_models(register)](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-embedding-models-register) + * [register_tools(register)](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-tools-register) + * [register_template_loaders(register)](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-template-loaders-register) + * [register_fragment_loaders(register)](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-fragment-loaders-register) + * [Developing a model plugin](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html) + * [The initial structure of the plugin](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#the-initial-structure-of-the-plugin) + * [Installing your plugin to try it out](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#installing-your-plugin-to-try-it-out) + * [Building the Markov chain](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#building-the-markov-chain) + * [Executing the Markov chain](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#executing-the-markov-chain) + * [Adding that to the plugin](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#adding-that-to-the-plugin) + * [Understanding execute()](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#understanding-execute) + * [Prompts and responses are logged to the database](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#prompts-and-responses-are-logged-to-the-database) + * [Adding options](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#adding-options) + * [Distributing your plugin](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#distributing-your-plugin) + * [GitHub repositories](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#github-repositories) + * [Publishing plugins to PyPI](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#publishing-plugins-to-pypi) + * [Adding metadata](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#adding-metadata) + * [What to do if it breaks](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#what-to-do-if-it-breaks) + * [Advanced model plugins](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html) + * [Tip: lazily load expensive dependencies](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#tip-lazily-load-expensive-dependencies) + * [Models that accept API keys](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#models-that-accept-api-keys) + * [Async models](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#async-models) + * [Supporting schemas](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#supporting-schemas) + * [Supporting tools](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#supporting-tools) + * [Attachments for multi-modal models](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#attachments-for-multi-modal-models) + * [Structured messages and streaming events](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#structured-messages-and-streaming-events) + * [Consuming prompt.messages in build_messages](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#consuming-prompt-messages-in-build-messages) + * [Restoring opaque metadata on subsequent requests](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#restoring-opaque-metadata-on-subsequent-requests) + * [Tracking token usage](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#tracking-token-usage) + * [Tracking resolved model names](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#tracking-resolved-model-names) + * [LLM_RAISE_ERRORS](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#llm-raise-errors) + * [Utility functions for plugins](https://llm.datasette.io/en/stable/plugins/plugin-utilities.html) + * [llm.get_key()](https://llm.datasette.io/en/stable/plugins/plugin-utilities.html#llm-get-key) + * [llm.user_dir()](https://llm.datasette.io/en/stable/plugins/plugin-utilities.html#llm-user-dir) + * [llm.ModelError](https://llm.datasette.io/en/stable/plugins/plugin-utilities.html#llm-modelerror) + * [Response.fake()](https://llm.datasette.io/en/stable/plugins/plugin-utilities.html#response-fake) +* [Python API](https://llm.datasette.io/en/stable/python-api.html) + * [Basic prompt execution](https://llm.datasette.io/en/stable/python-api.html#basic-prompt-execution) + * [System prompts](https://llm.datasette.io/en/stable/python-api.html#system-prompts) + * [Attachments](https://llm.datasette.io/en/stable/python-api.html#attachments) + * [Tools](https://llm.datasette.io/en/stable/python-api.html#tools) + * [Schemas](https://llm.datasette.io/en/stable/python-api.html#schemas) + * [Fragments](https://llm.datasette.io/en/stable/python-api.html#fragments) + * [Model options](https://llm.datasette.io/en/stable/python-api.html#model-options) + * [Passing an API key](https://llm.datasette.io/en/stable/python-api.html#passing-an-api-key) + * [Models from plugins](https://llm.datasette.io/en/stable/python-api.html#models-from-plugins) + * [Accessing the underlying JSON](https://llm.datasette.io/en/stable/python-api.html#accessing-the-underlying-json) + * [Token usage](https://llm.datasette.io/en/stable/python-api.html#token-usage) + * [Streaming responses](https://llm.datasette.io/en/stable/python-api.html#streaming-responses) + * [Structured messages and streaming events](https://llm.datasette.io/en/stable/python-api.html#structured-messages-and-streaming-events) + * [Async models](https://llm.datasette.io/en/stable/python-api.html#async-models) + * [`AsyncResponse`](https://llm.datasette.io/en/stable/python-api.html#llm.AsyncResponse) + * [Tool functions can be sync or async](https://llm.datasette.io/en/stable/python-api.html#tool-functions-can-be-sync-or-async) + * [Tool use for async models](https://llm.datasette.io/en/stable/python-api.html#tool-use-for-async-models) + * [Conversations](https://llm.datasette.io/en/stable/python-api.html#conversations) + * [Conversations using tools](https://llm.datasette.io/en/stable/python-api.html#conversations-using-tools) + * [Listing models](https://llm.datasette.io/en/stable/python-api.html#listing-models) + * [Running code when a response has completed](https://llm.datasette.io/en/stable/python-api.html#running-code-when-a-response-has-completed) + * [Other functions](https://llm.datasette.io/en/stable/python-api.html#other-functions) + * [set_alias(alias, model_id)](https://llm.datasette.io/en/stable/python-api.html#set-alias-alias-model-id) + * [remove_alias(alias)](https://llm.datasette.io/en/stable/python-api.html#remove-alias-alias) + * [set_default_model(alias)](https://llm.datasette.io/en/stable/python-api.html#set-default-model-alias) + * [get_default_model()](https://llm.datasette.io/en/stable/python-api.html#get-default-model) + * [set_default_embedding_model(alias) and get_default_embedding_model()](https://llm.datasette.io/en/stable/python-api.html#set-default-embedding-model-alias-and-get-default-embedding-model) +* [Logging to SQLite](https://llm.datasette.io/en/stable/logging.html) + * [Viewing the logs](https://llm.datasette.io/en/stable/logging.html#viewing-the-logs) + * [-s/–short mode](https://llm.datasette.io/en/stable/logging.html#s-short-mode) + * [Logs for a conversation](https://llm.datasette.io/en/stable/logging.html#logs-for-a-conversation) + * [Searching the logs](https://llm.datasette.io/en/stable/logging.html#searching-the-logs) + * [Filtering past a specific ID](https://llm.datasette.io/en/stable/logging.html#filtering-past-a-specific-id) + * [Filtering by model](https://llm.datasette.io/en/stable/logging.html#filtering-by-model) + * [Filtering by prompts that used specific fragments](https://llm.datasette.io/en/stable/logging.html#filtering-by-prompts-that-used-specific-fragments) + * [Filtering by prompts that used specific tools](https://llm.datasette.io/en/stable/logging.html#filtering-by-prompts-that-used-specific-tools) + * [Browsing data collected using schemas](https://llm.datasette.io/en/stable/logging.html#browsing-data-collected-using-schemas) + * [Browsing logs using Datasette](https://llm.datasette.io/en/stable/logging.html#browsing-logs-using-datasette) + * [Backing up your database](https://llm.datasette.io/en/stable/logging.html#backing-up-your-database) + * [SQL schema](https://llm.datasette.io/en/stable/logging.html#sql-schema) +* [Related tools](https://llm.datasette.io/en/stable/related-tools.html) + * [strip-tags](https://llm.datasette.io/en/stable/related-tools.html#strip-tags) + * [ttok](https://llm.datasette.io/en/stable/related-tools.html#ttok) + * [Symbex](https://llm.datasette.io/en/stable/related-tools.html#symbex) +* [CLI reference](https://llm.datasette.io/en/stable/help.html) + * [llm –help](https://llm.datasette.io/en/stable/help.html#llm-help) + * [llm prompt –help](https://llm.datasette.io/en/stable/help.html#llm-prompt-help) + * [llm chat –help](https://llm.datasette.io/en/stable/help.html#llm-chat-help) + * [llm keys –help](https://llm.datasette.io/en/stable/help.html#llm-keys-help) + * [llm logs –help](https://llm.datasette.io/en/stable/help.html#llm-logs-help) + * [llm models –help](https://llm.datasette.io/en/stable/help.html#llm-models-help) + * [llm templates –help](https://llm.datasette.io/en/stable/help.html#llm-templates-help) + * [llm schemas –help](https://llm.datasette.io/en/stable/help.html#llm-schemas-help) + * [llm tools –help](https://llm.datasette.io/en/stable/help.html#llm-tools-help) + * [llm aliases –help](https://llm.datasette.io/en/stable/help.html#llm-aliases-help) + * [llm fragments –help](https://llm.datasette.io/en/stable/help.html#llm-fragments-help) + * [llm plugins –help](https://llm.datasette.io/en/stable/help.html#llm-plugins-help) + * [llm install –help](https://llm.datasette.io/en/stable/help.html#llm-install-help) + * [llm uninstall –help](https://llm.datasette.io/en/stable/help.html#llm-uninstall-help) + * [llm embed –help](https://llm.datasette.io/en/stable/help.html#llm-embed-help) + * [llm embed-multi –help](https://llm.datasette.io/en/stable/help.html#llm-embed-multi-help) + * [llm similar –help](https://llm.datasette.io/en/stable/help.html#llm-similar-help) + * [llm embed-models –help](https://llm.datasette.io/en/stable/help.html#llm-embed-models-help) + * [llm collections –help](https://llm.datasette.io/en/stable/help.html#llm-collections-help) + * [llm openai –help](https://llm.datasette.io/en/stable/help.html#llm-openai-help) +* [Contributing](https://llm.datasette.io/en/stable/contributing.html) + * [Updating recorded HTTP API interactions and associated snapshots](https://llm.datasette.io/en/stable/contributing.html#updating-recorded-http-api-interactions-and-associated-snapshots) + * [Debugging tricks](https://llm.datasette.io/en/stable/contributing.html#debugging-tricks) + * [Documentation](https://llm.datasette.io/en/stable/contributing.html#documentation) + * [Release process](https://llm.datasette.io/en/stable/contributing.html#release-process) + +* [Changelog](https://llm.datasette.io/en/stable/changelog.html) + diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..3b48e72 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`simonw/llm` +- 原始仓库:https://github.com/simonw/llm +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..e35d885 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +_build diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..a279768 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,23 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = sqlite-utils +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +livehtml: + sphinx-autobuild -b html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0) diff --git a/docs/_templates/base.html b/docs/_templates/base.html new file mode 100644 index 0000000..822870d --- /dev/null +++ b/docs/_templates/base.html @@ -0,0 +1,16 @@ +{%- extends "!base.html" %} + +{%- block htmltitle -%} +{% if not docstitle %} + {{ title|striptags|e }} +{% elif pagename == master_doc %} + LLM: A CLI utility and Python library for interacting with Large Language Models +{% else %} + {{ title|striptags|e }} - {{ docstitle|striptags|e }} +{% endif %} +{%- endblock -%} + +{% block site_meta %} +{{ super() }} + +{% endblock %} diff --git a/docs/aliases.md b/docs/aliases.md new file mode 100644 index 0000000..ced77a5 --- /dev/null +++ b/docs/aliases.md @@ -0,0 +1,116 @@ +(aliases)= +# Model aliases + +LLM supports model aliases, which allow you to refer to a model by a short name instead of its full ID. + +## Listing aliases + +To list current aliases, run this: + +```bash +llm aliases +``` +Example output: + + +``` +4o : gpt-4o +chatgpt-4o : chatgpt-4o-latest +4o-mini : gpt-4o-mini +4.1 : gpt-4.1 +4.1-mini : gpt-4.1-mini +4.1-nano : gpt-4.1-nano +3.5 : gpt-3.5-turbo +chatgpt : gpt-3.5-turbo +chatgpt-16k : gpt-3.5-turbo-16k +3.5-16k : gpt-3.5-turbo-16k +4 : gpt-4 +gpt4 : gpt-4 +4-32k : gpt-4-32k +gpt-4-turbo-preview : gpt-4-turbo +4-turbo : gpt-4-turbo +4t : gpt-4-turbo +gpt-4.5 : gpt-4.5-preview +3.5-instruct : gpt-3.5-turbo-instruct +chatgpt-instruct : gpt-3.5-turbo-instruct +ada : text-embedding-ada-002 (embedding) +ada-002 : text-embedding-ada-002 (embedding) +3-small : text-embedding-3-small (embedding) +3-large : text-embedding-3-large (embedding) +3-small-512 : text-embedding-3-small-512 (embedding) +3-large-256 : text-embedding-3-large-256 (embedding) +3-large-1024 : text-embedding-3-large-1024 (embedding) +``` + + +Add `--json` to get that list back as JSON: + +```bash +llm aliases list --json +``` +Example output: +```json +{ + "3.5": "gpt-3.5-turbo", + "chatgpt": "gpt-3.5-turbo", + "4": "gpt-4", + "gpt4": "gpt-4", + "ada": "ada-002" +} +``` + +## Adding a new alias + +The `llm aliases set ` command can be used to add a new alias: + +```bash +llm aliases set mini gpt-4o-mini +``` +You can also pass one or more `-q search` options to set an alias on the first model matching those search terms: +```bash +llm aliases set mini -q 4o -q mini +``` +Now you can run the `gpt-4o-mini` model using the `mini` alias like this: +```bash +llm -m mini 'An epic Greek-style saga about a cheesecake that builds a SQL database from scratch' +``` +Aliases can be set for both regular models and {ref}`embedding models ` using the same command. To set an alias of `oai` for the OpenAI `ada-002` embedding model use this: +```bash +llm aliases set oai ada-002 +``` +Now you can embed a string using that model like so: +```bash +llm embed -c 'hello world' -m oai +``` +Output: +``` +[-0.014945968054234982, 0.0014304015785455704, ...] +``` + +## Removing an alias + +The `llm aliases remove ` command will remove the specified alias: + +```bash +llm aliases remove mini +``` + +## Viewing the aliases file + +Aliases are stored in an `aliases.json` file in the LLM configuration directory. + +To see the path to that file, run this: + +```bash +llm aliases path +``` +To view the content of that file, run this: + +```bash +cat "$(llm aliases path)" +``` \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..47f617c --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,1029 @@ +# Changelog + +(v0_31_1)= +## 0.31.1 (2026-07-09) + +- Fix for a bug with OpenAI Chat Completion endpoints where a tool call with empty arguments could result in a JSON error from some providers. [#1521](https://github.com/simonw/llm/issues/1521) + +(v0_32_a3)= +## 0.32a3 (2026-06-09) + +Driven by the needs of [Datasette Agent](https://github.com/datasette/datasette-agent)'s human-in-the-loop `ask_user()` feature, made the following improvements to how tool calls work: + +- Tool implementations can declare a parameter named `llm_tool_call` in order to be passed the `llm.ToolCall` object for the current invocation. This allows them to access the current `llm_tool_call.tool_call_id`. See {ref}`python-api-tools-llm-tool-call`. [#1480](https://github.com/simonw/llm/pull/1480) +- Every tool call is now guaranteed a unique `tool_call_id` - providers that do not supply one get a synthesized `tc_`-prefixed ULID. [#1481](https://github.com/simonw/llm/pull/1481) +- Tools can raise a `llm.PauseChain` exception to cleanly pause the tool chain, useful for things like waiting for human approval. The exception propagates to the caller with `.tool_call` and `.tool_results` (completed sibling results) attached, and no model call is made with a placeholder result. See {ref}`python-api-tools-pause`. [#1482](https://github.com/simonw/llm/pull/1482) +- Failure semantics for concurrent tool execution: async sibling tool calls always run to completion before a pause or hook exception propagates. [#1482](https://github.com/simonw/llm/pull/1482) +- Chains can now resume from a `messages=` history ending in unresolved tool calls: the calls are executed through the normal `before_call`/`after_call` machinery before the first model call, skipping any that already have results. The `execute_tool_calls()` method also accepts a new optional `tool_calls_list=` argument for executing an explicit list of `ToolCall` objects in place of the calls requested by the response. See {ref}`python-api-tools-resume`. [#1482](https://github.com/simonw/llm/pull/1482) +- Fixed a bug where the async tool executor silently dropped calls to tools not present in `tools=` - these now return `Error: tool "..." does not exist` results, matching the sync executor. [#1483](https://github.com/simonw/llm/pull/1483) + +(v0_32_a2)= +## 0.32a2 (2026-05-12) + +### Support for the OpenAI Responses API + +Most reasoning-capable OpenAI models now use the [`/v1/responses`](https://developers.openai.com/api/reference/responses/overview) endpoint instead of `/v1/chat/completions`. This enables interleaved reasoning across tool calls for GPT-5 class models. [#1435](https://github.com/simonw/llm/pull/1435) + +- New `Responses` and `AsyncResponses` model classes driving the OpenAI Responses API. The existing `Chat` and `AsyncChat` classes are unchanged so other plugins that import them keep working. +- The following models now use the Responses API by default: `o1`, `o3-mini`, `o3`, `o4-mini`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `gpt-5.1`, `gpt-5.2`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.5` (and their pinned date variants). +- Use `-o chat_completions 1` to fall back to the older `/v1/chat/completions` code path for any of these models. +- Encrypted reasoning items are captured as `provider_metadata` on `ReasoningPart` objects and round-tripped back to OpenAI on subsequent turns. +- Reasoning summaries are now requested with `"summary": "auto"` so visible reasoning text is streamed back where the model produces it, unless `--hide-reasoning` or `hide_reasoning=` is set. +- This means OpenAI prompts run using `llm prompt` that return reasoning tokens will display those on standard error. + +### CLI + +- New `llm -m model --options` flag to list the options supported by a given model. [#1441](https://github.com/simonw/llm/pull/1441) +- The `-R/--no-reasoning` option has been renamed to `-R/--hide-reasoning`. + +### Python API + +- New `hide_reasoning=True` keyword argument on `model.prompt()`, `conversation.prompt()`, `model.chain()`, `conversation.chain()`, and their async counterparts, exposed to model plugins as `prompt.hide_reasoning`. Model plugins can {ref}`use this to decide ` if they should request visible reasoning summaries from their providers. [#1442](https://github.com/simonw/llm/pull/1442) +- New `options=` dict keyword argument on `Model.prompt()`, `Conversation.prompt()`, `Response.reply()`, and their async equivalents, matching the pattern already used by `.chain()`. The previous `**kwargs` form continues to work for backwards compatibility but is no longer documented, and will be removed in the future. [#1432](https://github.com/simonw/llm/pull/1432) + +### Bug fixes + +- `add_tool_call()` calls that were not also recorded as stream events are now correctly emitted as `ToolCallPart` objects when assembling response parts, so they survive serialization via `response.to_dict()`. [#1433](https://github.com/simonw/llm/issues/1433) + +(v0_32_a1)= +## 0.32a1 (2026-04-29) + +- Fixed a bug in 0.32a0 where tool-calling conversations were not correctly reinflated from SQLite. [#1426](https://github.com/simonw/llm/issues/1426) + +(v0_32_a0)= +## 0.32a0 (2026-04-28) + +This alpha introduces a major backwards-compatible refactor. Models can now be prompted with a list of messages, OpenAI Chat Completions style, and the response can now be iterated over as a sequence of mixed types of content, for example reasoning tokens mixed with text tokens mixed with tool calls. + +For more background on this release take a look at [the annotated release notes](https://simonwillison.net/2026/Apr/29/llm/) on my blog. + +Prompt inputs and response outputs are now expressed as a list of `Message` objects, each containing typed `Part` objects (text, reasoning, tool calls, tool results, attachments). + +The `llm` CLI tool can now display reasoning tokens while executing a prompt. + +Plugin authors should read the expanded {ref}`Advanced model plugins ` documentation, which now covers `StreamEvent`, consuming `prompt.messages`, and round-tripping opaque provider metadata such as Anthropic extended-thinking signatures and Gemini `thoughtSignature` values. + +### Structured messages and streaming events + +- New `llm.Message` value type and constructor helpers `llm.user()`, `llm.assistant()`, `llm.system()`, and `llm.tool_message()` for building structured prompt inputs. The helpers accept strings, `Attachment` instances, or nested `Part` lists. +- New `messages=` keyword argument on `model.prompt()`, `conversation.prompt()`, `model.chain()`, `conversation.chain()`, and their async counterparts. The `prompt=`, `system=`, `attachments=`, and `tool_results=` keywords still work and synthesize into the same `Message` list internally. +- New `response.stream_events()` and `response.astream_events()` methods yielding typed `StreamEvent` objects (`type` is one of `"text"`, `"reasoning"`, `"tool_call_name"`, `"tool_call_args"`, `"tool_result"`, plus a `redacted=True` marker for opaque reasoning). Iterating against `response` directly continues to yield only text strings. +- New `response.messages()` method (async: `await response.messages()`) returning the assembled `list[Message]` produced by the model. Calling it forces execution if the response prompt has not yet been executed. +- New `response.reply(prompt=None, **kwargs)` method that continues the conversation from any `Response`, regardless of origin. When the previous response made tool calls and `tool_results=` was not passed, `reply()` automatically executes the pending tool calls and threads the results into the next turn. On async responses `reply()` is awaitable. +- New `response.to_dict()` and `Response.from_dict(data, *, model=None)` for JSON-safe serialization of a full conversation turn — model id, input chain, assembled output (including reasoning parts and provider metadata), options, and audit fields. Reasoning signatures and `thoughtSignature` values round-trip via `provider_metadata`, so multi-turn extended thinking works across process boundaries. +- New `llm/serialization.py` module exposing `MessageDict`, `PartDict`, `ResponseDict`, `PromptDict`, `UsageDict`, `AttachmentDict`, and the per-Part TypedDicts. Every `to_dict()` / `from_dict()` method is annotated with the matching TypedDict. +- `Response.prompt.messages` is now the canonical structured input across the entire conversation chain. `Conversation.prompt` and `AsyncConversation.prompt` pre-compute the full chain (prior input + prior output + new turn) before constructing the next `Prompt`, so `response.prompt.messages` is always exactly what the model was sent. + +### CLI + +- `llm prompt` and `llm chat` now display visible reasoning text to stderr in a dim style while the response streams. +- New `-R/--hide-reasoning` flag for `llm prompt` and `llm chat` to hide the reasoning stream. +- `llm logs` now renders any visible reasoning emitted during a response under a `## Reasoning` heading above the response. +- New `reasoning` column on the `responses` table populated from the visible-reasoning text. + +(v0_31)= +## 0.31 (2026-04-24) + +- New GPT-5.5 OpenAI model: `llm -m gpt-5.5`. [#1418](https://github.com/simonw/llm/issues/1418) +- New option to set the [text verbosity level](https://developers.openai.com/cookbook/examples/gpt-5/gpt-5_new_params_and_tools#1-verbosity-parameter) for GPT-5+ OpenAI models: `-o verbosity low`. Values are `low`, `medium`, `high`. +- New option for setting the [image detail level](https://developers.openai.com/api/docs/guides/images-vision#choose-an-image-detail-level) used for image attachments to OpenAI models: `-o image_detail low` - values are `low`, `high` and `auto`, and GPT-5.4 and 5.5 also accept `original`. +- Models listed in `extra-openai-models.yaml` are now also registered as asynchronous. [#1395](https://github.com/simonw/llm/issues/1395) + +(v0_30)= +## 0.30 (2026-03-31) + +- The {ref}`register_models() plugin hook ` now takes an optional `model_aliases` parameter listing all of the models, async models and aliases that have been registered so far by other plugins. A plugin with `@hookimpl(trylast=True)` can use this to take previously registered models into account. [#1389](https://github.com/simonw/llm/issues/1389) +- Added docstrings to public classes and methods and included those directly in the documentation. + +(v0_29)= +## 0.29 (2026-03-17) + +- The `-t/--template` option now works correctly with the `-x/--extract` and `--xl/--extract-last` flags. +- `llm logs` now shows any additional model options in the Markdown output. [#1322](https://github.com/simonw/llm/issues/1322) +- New OpenAI models: `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`. [#1376](https://github.com/simonw/llm/issues/1376) + +(v0_28)= +## 0.28 (2025-12-12) + +- New OpenAI models: `gpt-5.1`, `gpt-5.1-chat-latest`, `gpt-5.2` and `gpt-5.2-chat-latest`. [#1300](https://github.com/simonw/llm/issues/1300), [#1317](https://github.com/simonw/llm/issues/1317) +- LLM now requires Python 3.10 or higher. Python 3.14 is now covered by the tests. +- When fetching URLs as fragments using `llm -f URL`, the request now includes a custom user-agent header: `llm/VERSION (https://llm.datasette.io/)`. [#1309](https://github.com/simonw/llm/issues/1309) +- Fixed a bug where fragments were not correctly registered with their source when using `llm chat`. Thanks, [Giuseppe Rota](https://github.com/grota). [#1316](https://github.com/simonw/llm/pull/1316) +- Fixed some file descriptor leak warnings. Thanks, [Eric Bloch](https://github.com/eedeebee). [#1313](https://github.com/simonw/llm/issues/1313) +- Fixed a deprecation warning for `asyncio.iscoroutinefunction`. +- Type annotations for the OpenAI Chat, AsyncChat and Completion `execute()` methods. Thanks, [Arjan Mossel](https://github.com/ar-jan). [#1315](https://github.com/simonw/llm/pull/1315) +- The project now uses `uv` and dependency groups for development. See the updated {ref}`contributing documentation `. [#1318](https://github.com/simonw/llm/issues/1318) + +(v0_27_1)= +## 0.27.1 (2025-08-11) + +- `llm chat -t template` now correctly loads any tools that are included in that template. [#1239](https://github.com/simonw/llm/issues/1239) +- Fixed a bug where `llm -m gpt5 -o reasoning_effort minimal --save gm` saved a template containing invalid YAML. [#1237](https://github.com/simonw/llm/issues/1237) +- Fixed a bug where running `llm chat -t template` could cause prompts to be duplicated. [#1240](https://github.com/simonw/llm/issues/1240) +- Less confusing error message if a requested toolbox class is unavailable. [#1238](https://github.com/simonw/llm/issues/1238) + +(v0_27)= +## 0.27 (2025-08-11) + +This release adds support for the new **GPT-5 family** of models from OpenAI. It also enhances tool calling in a number of ways, including allowing {ref}`templates ` to bundle pre-configured tools. + +### New features + +- New models: `gpt-5`, `gpt-5-mini` and `gpt-5-nano`. [#1229](https://github.com/simonw/llm/issues/1229) +- LLM {ref}`templates ` can now include a list of tools. These can be named tools from plugins or arbitrary Python function blocks, see {ref}`Tools in templates `. [#1009](https://github.com/simonw/llm/issues/1009) +- Tools {ref}`can now return attachments `, for models that support features such as image input. [#1014](https://github.com/simonw/llm/issues/1014) +- New methods on the `Toolbox` class: `.add_tool()`, `.prepare()` and `.prepare_async()`, described in {ref}`Dynamic toolboxes `. [#1111](https://github.com/simonw/llm/issues/1111) +- New `model.conversation(before_call=x, after_call=y)` attributes for registering callback functions to run before and after tool calls. See {ref}`tool debugging hooks ` for details. [#1088](https://github.com/simonw/llm/issues/1088) +- Some model providers can serve different models from the same configured URL - [llm-llama-server](https://github.com/simonw/llm-llama-server) for example. Plugins for these providers can now record the resolved model ID of the model that was used to the LLM logs using the `response.set_resolved_model(model_id)` method. [#1117](https://github.com/simonw/llm/issues/1117) +- Raising `llm.CancelToolCall` now only cancels the current tool call, passing an error back to the model and allowing it to continue. [#1148](https://github.com/simonw/llm/issues/1148) +- New `-l/--latest` option for `llm logs -q searchterm` for searching logs ordered by date (most recent first) instead of the default relevance search. [#1177](https://github.com/simonw/llm/issues/1177) + +### Bug fixes and documentation + +- Fix for various bugs with different formats of streaming function responses for OpenAI-compatible models. Thanks, [James Sanford](https://github.com/jamessanford). [#1218](https://github.com/simonw/llm/pull/1218) +- The `register_embedding_models` hook is [now documented](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-embedding-models-register). [#1049](https://github.com/simonw/llm/issues/1049) +- Show visible stack trace for `llm templates show invalid-template-name`. [#1053](https://github.com/simonw/llm/issues/1053) +- Handle invalid tool names more gracefully in `llm chat`. [#1104](https://github.com/simonw/llm/issues/1104) +- Add a {ref}`Tool plugins ` section to the plugin directory. [#1110](https://github.com/simonw/llm/issues/1110) +- Error on `register(Klass)` if the passed class is not a subclass of `Toolbox`. [#1114](https://github.com/simonw/llm/issues/1114) +- Add `-h` for `--help` for all `llm` CLI commands. [#1134](https://github.com/simonw/llm/issues/1134) +- Add missing `dataclasses` to advanced model plugins docs. [#1137](https://github.com/simonw/llm/issues/1137) +- Fixed a bug where `llm logs -T llm_version "version" --async` incorrectly recorded just one single log entry when it should have recorded two. [#1150](https://github.com/simonw/llm/issues/1150) +- All extra OpenAI model keys in `extra-openai-models.yaml` are {ref}`now documented `. [#1228](https://github.com/simonw/llm/issues/1228) + +(v0_26)= +## 0.26 (2025-05-27) + +**Tool support** is finally here! This release adds support exposing {ref}`tools ` to LLMs, previously described in the release notes for {ref}`0.26a0 ` and {ref}`0.26a1 `. + +Read **[Large Language Models can run tools in your terminal with LLM 0.26](https://simonwillison.net/2025/May/27/llm-tools/)** for a detailed overview of the new features. + +Also in this release: + +- Two new {ref}`default tools `: `llm_version()` and `llm_time()`. [#1096](https://github.com/simonw/llm/issues/1096), [#1103](https://github.com/simonw/llm/issues/1103) +- Documentation on {ref}`how to add tool supports to a model plugin `. [#1000](https://github.com/simonw/llm/issues/1000) +- Added a {ref}`prominent warning ` about the risk of prompt injection when using tools. [#1097](https://github.com/simonw/llm/issues/1097) +- Switched to using monotonic ULIDs for the response IDs in the logs, fixing some intermittent test failures. [#1099](https://github.com/simonw/llm/issues/1099) +- New `tool_instances` table records details of Toolbox instances created while executing a prompt. [#1089](https://github.com/simonw/llm/issues/1089) +- `llm.get_key()` is now a {ref}`documented utility function `. [#1094](https://github.com/simonw/llm/issues/1094) + +(v0_26_a1)= +## 0.26a1 (2025-05-25) + +Hopefully the last alpha before a stable release that includes tool support. + +### Features + +* **Plugin-provided tools can now be grouped into "Toolboxes".** + * Toolboxes (`llm.Toolbox` classes) allow plugins to expose multiple related tools that share state or configuration (e.g., a `Memory` tool or `Filesystem` tool). ([#1059](https://github.com/simonw/llm/issues/1059), [#1086](https://github.com/simonw/llm/issues/1086)) +* **Tool support for `llm chat`.** + * The `llm chat` command now accepts `--tool` and `--functions` arguments, allowing interactive chat sessions to use tools. ([#1004](https://github.com/simonw/llm/issues/1004), [#1062](https://github.com/simonw/llm/issues/1062)) +* **Tools can now execute asynchronously.** + * Models that implement `AsyncModel` can now run tools, including tool functions defined as `async def`. ([#1063](https://github.com/simonw/llm/issues/1063)) +* **`llm chat` now supports adding fragments during a session.** + * Use the new `!fragment ` command while chatting to insert content from a fragment. Initial fragments can also be passed to `llm chat` using `-f` or `--sf`. Thanks, [Dan Turkel](https://github.com/daturkel). ([#1044](https://github.com/simonw/llm/issues/1044), [#1048](https://github.com/simonw/llm/issues/1048)) +* **Filter `llm logs` by tools.** + * New `--tool ` option to filter logs to show only responses that involved a specific tool (e.g., `--tool simple_eval`). + * The `--tools` flag shows all responses that used any tool. ([#1013](https://github.com/simonw/llm/issues/1013), [#1072](https://github.com/simonw/llm/issues/1072)) +* **`llm schemas list` can output JSON.** + * Added `--json` and `--nl` (newline-delimited JSON) options to `llm schemas list` for programmatic access to saved schema definitions. ([#1070](https://github.com/simonw/llm/issues/1070)) +* **Filter `llm similar` results by ID prefix.** + * The new `--prefix` option for `llm similar` allows searching for similar items only within IDs that start with a specified string (e.g., `llm similar my-collection --prefix 'docs/'`). Thanks, [Dan Turkel](https://github.com/daturkel). ([#1052](https://github.com/simonw/llm/issues/1052)) +* **Control chained tool execution limit.** + * New `--chain-limit ` (or `--cl`) option for `llm prompt` and `llm chat` to specify the maximum number of consecutive tool calls allowed for a single prompt. Defaults to 5; set to 0 for unlimited. ([#1025](https://github.com/simonw/llm/issues/1025)) +* **`llm plugins --hook ` option.** + * Filter the list of installed plugins to only show those that implement a specific plugin hook. ([#1047](https://github.com/simonw/llm/issues/1047)) +* `llm tools list` now shows toolboxes and their methods. ([#1013](https://github.com/simonw/llm/issues/1013)) +* `llm prompt` and `llm chat` now automatically re-enable plugin-provided tools when continuing a conversation (`-c` or `--cid`). ([#1020](https://github.com/simonw/llm/issues/1020)) +* The `--tools-debug` option now pretty-prints JSON tool results for improved readability. ([#1083](https://github.com/simonw/llm/issues/1083)) +* New `LLM_TOOLS_DEBUG` environment variable to permanently enable `--tools-debug`. ([#1045](https://github.com/simonw/llm/issues/1045)) +* `llm chat` sessions now correctly respect default model options configured with `llm models set-options`. Thanks, [André Arko](https://github.com/indirect). ([#985](https://github.com/simonw/llm/issues/985)) +* New `--pre` option for `llm install` to allow installing pre-release packages. ([#1060](https://github.com/simonw/llm/issues/1060)) +* OpenAI models (`gpt-4o`, `gpt-4o-mini`) now explicitly declare support for tools and vision. ([#1037](https://github.com/simonw/llm/issues/1037)) +* The `supports_tools` parameter is now supported in `extra-openai-models.yaml`. Thanks, [Mahesh Hegde ](https://github.com/mahesh-hegde). ([#1068](https://github.com/simonw/llm/issues/1068)) + +### Bug fixes + +* Fixed a bug where the `name` parameter in `register(function, name="name")` was ignored for tool plugins. ([#1032](https://github.com/simonw/llm/issues/1032)) +* Ensure `pathlib.Path` objects are cast to `str` before passing to `click.edit` in `llm templates edit`. Thanks, [Abizer Lokhandwala](https://github.com/abizer). ([#1031](https://github.com/simonw/llm/issues/1031)) + + +(v0_26_a0)= +## 0.26a0 (2025-05-13) + +This is the first alpha to introduce {ref}`support for tools`! Models with tool capability (which includes the default OpenAI model family) can now be granted access to execute Python functions as part of responding to a prompt. + +Tools are supported by {ref}`the command-line interface `: + +```bash +llm --functions ' +def multiply(x: int, y: int) -> int: + """Multiply two numbers.""" + return x * y +' 'what is 34234 * 213345' +``` +And in {ref}`the Python API `, using a new `model.chain()` method for executing multiple prompts in a sequence: +```python +import llm + +def multiply(x: int, y: int) -> int: + """Multiply two numbers.""" + return x * y + +model = llm.get_model("gpt-4.1-mini") +response = model.chain( + "What is 34234 * 213345?", + tools=[multiply] +) +print(response.text()) +``` +New tools can also be defined using the {ref}`register_tools() plugin hook `. They can then be called by name from the command-line like this: +```bash +llm -T multiply 'What is 34234 * 213345?' +``` +Tool support is currently under **active development**. Consult [this milestone](https://github.com/simonw/llm/milestone/12) for the latest status. + +(v0_25)= +## 0.25 (2025-05-04) + +- New plugin feature: {ref}`plugin-hooks-register-fragment-loaders` plugins can now return a mixture of fragments and attachments. The [llm-video-frames](https://github.com/simonw/llm-video-frames) plugin is the first to take advantage of this mechanism. [#972](https://github.com/simonw/llm/issues/972) +- New OpenAI models: `gpt-4.1`, `gpt-4.1-mini`, `gpt-41-nano`, `o3`, `o4-mini`. [#945](https://github.com/simonw/llm/issues/945), [#965](https://github.com/simonw/llm/issues/965), [#976](https://github.com/simonw/llm/issues/976). +- New environment variables: `LLM_MODEL` and `LLM_EMBEDDING_MODEL` for setting the model to use without needing to specify `-m model_id` every time. [#932](https://github.com/simonw/llm/issues/932) +- New command: `llm fragments loaders`, to list all currently available fragment loader prefixes provided by plugins. [#941](https://github.com/simonw/llm/issues/941) +- `llm fragments` command now shows fragments ordered by the date they were first used. [#973](https://github.com/simonw/llm/issues/973) +- `llm chat` now includes a `!edit` command for editing a prompt using your default terminal text editor. Thanks, [Benedikt Willi](https://github.com/Hopiu). [#969](https://github.com/simonw/llm/pull/969) +- Allow `-t` and `--system` to be used at the same time. [#916](https://github.com/simonw/llm/issues/916) +- Fixed a bug where accessing a model via its alias would fail to respect any default options set for that model. [#968](https://github.com/simonw/llm/issues/968) +- Improved documentation for {ref}`extra-openai-models.yaml `. Thanks, [Rahim Nathwani](https://github.com/rahimnathwani) and [Dan Guido](https://github.com/dguido). [#950](https://github.com/simonw/llm/pull/950), [#957](https://github.com/simonw/llm/pull/957) +- `llm -c/--continue` now works correctly with the `-d/--database` option. `llm chat` now accepts that `-d/--database` option. Thanks, [Sukhbinder Singh](https://github.com/sukhbinder). [#933](https://github.com/simonw/llm/issues/933) + +(v0_25a0)= +## 0.25a0 (2025-04-10) + +- `llm models --options` now shows keys and environment variables for models that use API keys. Thanks, [Steve Morin](https://github.com/smorin). [#903](https://github.com/simonw/llm/issues/903) +- Added `py.typed` marker file so LLM can now be used as a dependency in projects that use `mypy` without a warning. [#887](https://github.com/simonw/llm/issues/887) +- `$` characters can now be used in templates by escaping them as `$$`. Thanks, [@guspix](https://github.com/guspix). [#904](https://github.com/simonw/llm/issues/904) +- LLM now uses `pyproject.toml` instead of `setup.py`. [#908](https://github.com/simonw/llm/issues/908) + +(v0_24_2)= +## 0.24.2 (2025-04-08) + +- Fixed a bug on Windows with the new `llm -t path/to/file.yaml` feature. [#901](https://github.com/simonw/llm/issues/901) + +(v0_24_1)= +## 0.24.1 (2025-04-08) + +- Templates can now be specified as a path to a file on disk, using `llm -t path/to/file.yaml`. This makes them consistent with how `-f` fragments are loaded. [#897](https://github.com/simonw/llm/issues/897) +- `llm logs backup /tmp/backup.db` command for {ref}`backing up your ` `logs.db` database. [#879](https://github.com/simonw/llm/issues/879) + +(v0_24)= +## 0.24 (2025-04-07) + +Support for **fragments** to help assemble prompts for long context models. Improved support for **templates** to support attachments and fragments. New plugin hooks for providing custom loaders for both templates and fragments. See [Long context support in LLM 0.24 using fragments and template plugins](https://simonwillison.net/2025/Apr/7/long-context-llm/) for more on this release. + +The new [llm-docs](https://github.com/simonw/llm-docs) plugin demonstrates these new features. Install it like this: + +```bash +llm install llm-docs +``` +Now you can ask questions of the LLM documentation like this: + +```bash +llm -f docs: 'How do I save a new template?' +``` +The `docs:` prefix is registered by the plugin. The plugin fetches the LLM documentation for your installed version (from the [docs-for-llms](https://github.com/simonw/docs-for-llms) repository) and uses that as a prompt fragment to help answer your question. + +Two more new plugins are [llm-templates-github](https://github.com/simonw/llm-templates-github) and [llm-templates-fabric](https://github.com/simonw/llm-templates-fabric). + +`llm-templates-github` lets you share and use templates on GitHub. You can run my [Pelican riding a bicycle](https://simonwillison.net/tags/pelican-riding-a-bicycle/) benchmark against a model like this: + +```bash +llm install llm-templates-github +llm -t gh:simonw/pelican-svg -m o3-mini +``` +This executes [this pelican-svg.yaml](https://github.com/simonw/llm-templates/blob/main/pelican-svg.yaml) template stored in my [simonw/llm-templates](https://github.com/simonw/llm-templates) repository, using a new repository naming convention. + +To share your own templates, create a repository on GitHub under your user account called `llm-templates` and start saving `.yaml` files to it. + +[llm-templates-fabric](https://github.com/simonw/llm-templates-fabric) provides a similar mechanism for loading templates from Daniel Miessler's [fabric collection](https://github.com/danielmiessler/fabric): + +```bash +llm install llm-templates-fabric +curl https://simonwillison.net/2025/Apr/6/only-miffy/ | \ + llm -t f:extract_main_idea +``` + +Major new features: + +- New {ref}`fragments feature `. Fragments can be used to assemble long prompts from multiple existing pieces - URLs, file paths or previously used fragments. These will be stored de-duplicated in the database avoiding wasting space storing multiple long context pieces. Example usage: `llm -f https://llm.datasette.io/robots.txt 'explain this file'`. [#617](https://github.com/simonw/llm/issues/617) +- The `llm logs` file now accepts `-f` fragment references too, and will show just logged prompts that used those fragments. +- {ref}`register_template_loaders() plugin hook ` allowing plugins to register new `prefix:value` custom template loaders. [#809](https://github.com/simonw/llm/issues/809) +- {ref}`register_fragment_loaders() plugin hook ` allowing plugins to register new `prefix:value` custom fragment loaders. [#886](https://github.com/simonw/llm/issues/886) +- {ref}`llm fragments ` family of commands for browsing fragments that have been previously logged to the database. +- The new [llm-openai plugin](https://github.com/simonw/llm-openai-plugin) provides support for **o1-pro** (which is not supported by the OpenAI mechanism used by LLM core). Future OpenAI features will migrate to this plugin instead of LLM core itself. + +Improvements to templates: + +- `llm -t $URL` option can now take a URL to a YAML template. [#856](https://github.com/simonw/llm/issues/856) +- Templates can now store default model options. [#845](https://github.com/simonw/llm/issues/845) +- Executing a template that does not use the `$input` variable no longer blocks LLM waiting for input, so prompt templates can now be used to try different models using `llm -t pelican-svg -m model_id`. [#835](https://github.com/simonw/llm/issues/835) +- `llm templates` command no longer crashes if one of the listed template files contains invalid YAML. [#880](https://github.com/simonw/llm/issues/880) +- Attachments can now be stored in templates. [#826](https://github.com/simonw/llm/issues/826) + +Other changes: + +- New {ref}`llm models options ` family of commands for setting default options for particular models. [#829](https://github.com/simonw/llm/issues/829) +- `llm logs list`, `llm schemas list` and `llm schemas show` all now take a `-d/--database` option with an optional path to a SQLite database. They used to take `-p/--path` but that was inconsistent with other commands. `-p/--path` still works but is excluded from `--help` and will be removed in a future LLM release. [#857](https://github.com/simonw/llm/issues/857) +- `llm logs -e/--expand` option for expanding fragments. [#881](https://github.com/simonw/llm/issues/881) +- `llm prompt -d path-to-sqlite.db` option can now be used to write logs to a custom SQLite database. [#858](https://github.com/simonw/llm/issues/858) +- `llm similar -p/--plain` option providing more human-readable output than the default JSON. [#853](https://github.com/simonw/llm/issues/853) +- `llm logs -s/--short` now truncates to include the end of the prompt too. Thanks, [Sukhbinder Singh](https://github.com/sukhbinder). [#759](https://github.com/simonw/llm/issues/759) +- Set the `LLM_RAISE_ERRORS=1` environment variable to raise errors during prompts rather than suppressing them, which means you can run `python -i -m llm 'prompt'` and then drop into a debugger on errors with `import pdb; pdb.pm()`. [#817](https://github.com/simonw/llm/issues/817) +- Improved [--help output](https://llm.datasette.io/en/stable/help.html#llm-embed-multi-help) for `llm embed-multi`. [#824](https://github.com/simonw/llm/issues/824) +- `llm models -m X` option which can be passed multiple times with model IDs to see the details of just those models. [#825](https://github.com/simonw/llm/issues/825) +- OpenAI models now accept PDF attachments. [#834](https://github.com/simonw/llm/issues/834) +- `llm prompt -q gpt -q 4o` option - pass `-q searchterm` one or more times to execute a prompt against the first model that matches all of those strings - useful for if you can't remember the full model ID. [#841](https://github.com/simonw/llm/issues/841) +- {ref}`OpenAI compatible models ` configured using `extra-openai-models.yaml` now support `supports_schema: true`, `vision: true` and `audio: true` options. Thanks [@adaitche](https://github.com/adaitche) and [@giuli007](https://github.com/giuli007). [#819](https://github.com/simonw/llm/pull/819), [#843](https://github.com/simonw/llm/pull/843) + + +(v0_24a1)= +## 0.24a1 (2025-04-06) + +- New Fragments feature. [#617](https://github.com/simonw/llm/issues/617) +- `register_fragment_loaders()` plugin hook. [#809](https://github.com/simonw/llm/issues/886) + +(v0_24a0)= +## 0.24a0 (2025-02-28) + +- Alpha release with experimental `register_template_loaders()` plugin hook. [#809](https://github.com/simonw/llm/issues/809) + +(v0_23)= +## 0.23 (2025-02-28) + +Support for **schemas**, for getting supported models to output JSON that matches a specified JSON schema. See also [Structured data extraction from unstructured content using LLM schemas](https://simonwillison.net/2025/Feb/28/llm-schemas/) for background on this feature. [#776](https://github.com/simonw/llm/issues/776) + +- New `llm prompt --schema '{JSON schema goes here}` option for specifying a schema that should be used for the output from the model. The {ref}`schemas documentation ` has more details and a tutorial. +- Schemas can also be defined using a {ref}`concise schema specification `, for example `llm prompt --schema 'name, bio, age int'`. [#790](https://github.com/simonw/llm/issues/790) +- Schemas can also be specified by passing a filename and through {ref}`several other methods `. [#780](https://github.com/simonw/llm/issues/780) +- New {ref}`llm schemas family of commands `: `llm schemas list`, `llm schemas show`, and `llm schemas dsl` for debugging the new concise schema language. [#781](https://github.com/simonw/llm/issues/781) +- Schemas can now be saved to templates using `llm --schema X --save template-name` or through modifying the {ref}`template YAML `. [#778](https://github.com/simonw/llm/issues/778) +- The {ref}`llm logs ` command now has new options for extracting data collected using schemas: `--data`, `--data-key`, `--data-array`, `--data-ids`. [#782](https://github.com/simonw/llm/issues/782) +- New `llm logs --id-gt X` and `--id-gte X` options. [#801](https://github.com/simonw/llm/issues/801) +- New `llm models --schemas` option for listing models that support schemas. [#797](https://github.com/simonw/llm/issues/797) +- `model.prompt(..., schema={...})` parameter for specifying a schema from Python. This accepts either a dictionary JSON schema definition or a Pydantic `BaseModel` subclass, see {ref}`schemas in the Python API docs `. +- The default OpenAI plugin now enables schemas across all supported models. Run `llm models --schemas` for a list of these. +- The [llm-anthropic](https://github.com/simonw/llm-anthropic) and [llm-gemini](https://github.com/simonw/llm-gemini) plugins have been upgraded to add schema support for those models. Here's documentation on how to {ref}`add schema support to a model plugin `. + +Other smaller changes: + +- [GPT-4.5 preview](https://openai.com/index/introducing-gpt-4-5/) is now a supported model: `llm -m gpt-4.5 'a joke about a pelican and a wolf'` [#795](https://github.com/simonw/llm/issues/795) +- The prompt string is now optional when calling `model.prompt()` from the Python API, so `model.prompt(attachments=llm.Attachment(url=url)))` now works. [#784](https://github.com/simonw/llm/issues/784) +- `extra-openai-models.yaml` now supports a `reasoning: true` option. Thanks, [Kasper Primdal Lauritzen](https://github.com/KPLauritzen). [#766](https://github.com/simonw/llm/pull/766) +- LLM now depends on Pydantic v2 or higher. Pydantic v1 is no longer supported. [#520](https://github.com/simonw/llm/issues/520) + + +(v0_22)= +## 0.22 (2025-02-16) + +See also [LLM 0.22, the annotated release notes](https://simonwillison.net/2025/Feb/17/llm/). + +- Plugins that provide models that use API keys can now subclass the new `llm.KeyModel` and `llm.AsyncKeyModel` classes. This results in the API key being passed as a new `key` parameter to their `.execute()` methods, and means that Python users can pass a key as the `model.prompt(..., key=)` - see {ref}`Passing an API key `. Plugin developers should consult the new documentation on writing {ref}`Models that accept API keys `. [#744](https://github.com/simonw/llm/issues/744) +- New OpenAI model: `chatgpt-4o-latest`. This model ID accesses the current model being used to power ChatGPT, which can change without warning. [#752](https://github.com/simonw/llm/issues/752) +- New `llm logs -s/--short` flag, which returns a greatly shortened version of the matching log entries in YAML format with a truncated prompt and without including the response. [#737](https://github.com/simonw/llm/issues/737) +- Both `llm models` and `llm embed-models` now take multiple `-q` search fragments. You can now search for all models matching "gemini" and "exp" using `llm models -q gemini -q exp`. [#748](https://github.com/simonw/llm/issues/748) +- New `llm embed-multi --prepend X` option for prepending a string to each value before it is embedded - useful for models such as [nomic-embed-text-v2-moe](https://huggingface.co/nomic-ai/nomic-embed-text-v2-moe) that require passages to start with a string like `"search_document: "`. [#745](https://github.com/simonw/llm/issues/745) +- The `response.json()` and `response.usage()` methods are {ref}`now documented `. +- Fixed a bug where conversations that were loaded from the database could not be continued using `asyncio` prompts. [#742](https://github.com/simonw/llm/issues/742) +- New plugin for macOS users: [llm-mlx](https://github.com/simonw/llm-mlx), which provides [extremely high performance access](https://simonwillison.net/2025/Feb/15/llm-mlx/) to a wide range of local models using Apple's MLX framework. +- The `llm-claude-3` plugin has been renamed to [llm-anthropic](https://github.com/simonw/llm-anthropic). + +(v0_21)= +## 0.21 (2025-01-31) + +- New model: `o3-mini`. [#728](https://github.com/simonw/llm/issues/728) +- The `o3-mini` and `o1` models now support a `reasoning_effort` option which can be set to `low`, `medium` or `high`. +- `llm prompt` and `llm logs` now have a `--xl/--extract-last` option for extracting the last fenced code block in the response - a complement to the existing `--x/--extract` option. [#717](https://github.com/simonw/llm/issues/717) + +(v0_20)= +## 0.20 (2025-01-22) + +- New model, `o1`. This model does not yet support streaming. [#676](https://github.com/simonw/llm/issues/676) +- `o1-preview` and `o1-mini` models now support streaming. +- New models, `gpt-4o-audio-preview` and `gpt-4o-mini-audio-preview`. [#677](https://github.com/simonw/llm/issues/677) +- `llm prompt -x/--extract` option, which returns just the content of the first fenced code block in the response. Try `llm prompt -x 'Python function to reverse a string'`. [#681](https://github.com/simonw/llm/issues/681) + - Creating a template using `llm ... --save x` now supports the `-x/--extract` option, which is saved to the template. YAML templates can set this option using `extract: true`. + - New `llm logs -x/--extract` option extracts the first fenced code block from matching logged responses. +- New `llm models -q 'search'` option returning models that case-insensitively match the search query. [#700](https://github.com/simonw/llm/issues/700) +- Installation documentation now also includes `uv`. Thanks, [Ariel Marcus](https://github.com/ajmarcus). [#690](https://github.com/simonw/llm/pull/690) and [#702](https://github.com/simonw/llm/issues/702) +- `llm models` command now shows the current default model at the bottom of the listing. Thanks, [Amjith Ramanujam](https://github.com/amjith). [#688](https://github.com/simonw/llm/pull/688) +- {ref}`Plugin directory ` now includes `llm-venice`, `llm-bedrock`, `llm-deepseek` and `llm-cmd-comp`. +- Fixed bug where some dependency version combinations could cause a `Client.__init__() got an unexpected keyword argument 'proxies'` error. [#709](https://github.com/simonw/llm/issues/709) +- OpenAI embedding models are now available using their full names of `text-embedding-ada-002`, `text-embedding-3-small` and `text-embedding-3-large` - the previous names are still supported as aliases. Thanks, [web-sst](https://github.com/web-sst). [#654](https://github.com/simonw/llm/pull/654) + +(v0_19_1)= +## 0.19.1 (2024-12-05) + +- FIxed bug where `llm.get_models()` and `llm.get_async_models()` returned the same model multiple times. [#667](https://github.com/simonw/llm/issues/667) + +(v0_19)= +## 0.19 (2024-12-01) + +- Tokens used by a response are now logged to new `input_tokens` and `output_tokens` integer columns and a `token_details` JSON string column, for the default OpenAI models and models from other plugins that {ref}`implement this feature `. [#610](https://github.com/simonw/llm/issues/610) +- `llm prompt` now takes a `-u/--usage` flag to display token usage at the end of the response. +- `llm logs -u/--usage` shows token usage information for logged responses. +- `llm prompt ... --async` responses are now logged to the database. [#641](https://github.com/simonw/llm/issues/641) +- `llm.get_models()` and `llm.get_async_models()` functions, {ref}`documented here `. [#640](https://github.com/simonw/llm/issues/640) +- `response.usage()` and async response `await response.usage()` methods, returning a `Usage(input=2, output=1, details=None)` dataclass. [#644](https://github.com/simonw/llm/issues/644) +- `response.on_done(callback)` and `await response.on_done(callback)` methods for specifying a callback to be executed when a response has completed, {ref}`documented here `. [#653](https://github.com/simonw/llm/issues/653) +- Fix for bug running `llm chat` on Windows 11. Thanks, [Sukhbinder Singh](https://github.com/sukhbinder). [#495](https://github.com/simonw/llm/issues/495) + +(v0_19a2)= +## 0.19a2 (2024-11-20) + +- `llm.get_models()` and `llm.get_async_models()` functions, {ref}`documented here `. [#640](https://github.com/simonw/llm/issues/640) + +(v0_19a1)= +## 0.19a1 (2024-11-19) + +- `response.usage()` and async response `await response.usage()` methods, returning a `Usage(input=2, output=1, details=None)` dataclass. [#644](https://github.com/simonw/llm/issues/644) + +(v0_19a0)= +## 0.19a0 (2024-11-19) + +- Tokens used by a response are now logged to new `input_tokens` and `output_tokens` integer columns and a `token_details` JSON string column, for the default OpenAI models and models from other plugins that {ref}`implement this feature `. [#610](https://github.com/simonw/llm/issues/610) +- `llm prompt` now takes a `-u/--usage` flag to display token usage at the end of the response. +- `llm logs -u/--usage` shows token usage information for logged responses. +- `llm prompt ... --async` responses are now logged to the database. [#641](https://github.com/simonw/llm/issues/641) + +(v0_18)= +## 0.18 (2024-11-17) + +- Initial support for async models. Plugins can now provide an `AsyncModel` subclass that can be accessed in the Python API using the new `llm.get_async_model(model_id)` method. See {ref}`async models in the Python API docs` and {ref}`implementing async models in plugins `. [#507](https://github.com/simonw/llm/issues/507) +- OpenAI models all now include async models, so function calls such as `llm.get_async_model("gpt-4o-mini")` will return an async model. +- `gpt-4o-audio-preview` model can be used to send audio attachments to the GPT-4o audio model. [#608](https://github.com/simonw/llm/issues/608) +- Attachments can now be sent without requiring a prompt. [#611](https://github.com/simonw/llm/issues/611) +- `llm models --options` now includes information on whether a model supports attachments. [#612](https://github.com/simonw/llm/issues/612) +- `llm models --async` shows available async models. +- Custom OpenAI-compatible models can now be marked as `can_stream: false` in the YAML if they do not support streaming. Thanks, [Chris Mungall](https://github.com/cmungall). [#600](https://github.com/simonw/llm/pull/600) +- Fixed bug where OpenAI usage data was incorrectly serialized to JSON. [#614](https://github.com/simonw/llm/issues/614) +- Standardized on `audio/wav` MIME type for audio attachments rather than `audio/wave`. [#603](https://github.com/simonw/llm/issues/603) + +(v0_18a1)= +## 0.18a1 (2024-11-14) + +- Fixed bug where conversations did not work for async OpenAI models. [#632](https://github.com/simonw/llm/issues/632) +- `__repr__` methods for `Response` and `AsyncResponse`. + +(v0_18a0)= +## 0.18a0 (2024-11-13) + +Alpha support for **async models**. [#507](https://github.com/simonw/llm/issues/507) + +Multiple [smaller changes](https://github.com/simonw/llm/compare/0.17.1...0.18a0). + +(v0_17)= +## 0.17 (2024-10-29) + +Support for **attachments**, allowing multi-modal models to accept images, audio, video and other formats. [#578](https://github.com/simonw/llm/issues/578) + +The default OpenAI `gpt-4o` and `gpt-4o-mini` models can both now be prompted with JPEG, GIF, PNG and WEBP images. + +Attachments {ref}`in the CLI ` can be URLs: + +```bash +llm -m gpt-4o "describe this image" \ + -a https://static.simonwillison.net/static/2024/pelicans.jpg +``` +Or file paths: +```bash +llm -m gpt-4o-mini "extract text" -a image1.jpg -a image2.jpg +``` +Or binary data, which may need to use `--attachment-type` to specify the MIME type: +```bash +cat image | llm -m gpt-4o-mini "extract text" --attachment-type - image/jpeg +``` + +Attachments are also available {ref}`in the Python API `: + +```python +model = llm.get_model("gpt-4o-mini") +response = model.prompt( + "Describe these images", + attachments=[ + llm.Attachment(path="pelican.jpg"), + llm.Attachment(url="https://static.simonwillison.net/static/2024/pelicans.jpg"), + ] +) +``` +Plugins that provide alternative models can support attachments, see {ref}`advanced-model-plugins-attachments` for details. + +The latest **[llm-claude-3](https://github.com/simonw/llm-claude-3)** plugin now supports attachments for Anthropic's Claude 3 and 3.5 models. The **[llm-gemini](https://github.com/simonw/llm-gemini)** plugin supports attachments for Google's Gemini 1.5 models. + +Also in this release: OpenAI models now record their `"usage"` data in the database even when the response was streamed. These records can be viewed using `llm logs --json`. [#591](https://github.com/simonw/llm/issues/591) + +(v0_17a0)= +## 0.17a0 (2024-10-28) + +Alpha support for **attachments**. [#578](https://github.com/simonw/llm/issues/578) + +(v0_16)= +## 0.16 (2024-09-12) + +- OpenAI models now use the internal `self.get_key()` mechanism, which means they can be used from Python code in a way that will pick up keys that have been configured using `llm keys set` or the `OPENAI_API_KEY` environment variable. [#552](https://github.com/simonw/llm/issues/552). This code now works correctly: + ```python + import llm + print(llm.get_model("gpt-4o-mini").prompt("hi")) + ``` +- New documented API methods: `llm.get_default_model()`, `llm.set_default_model(alias)`, `llm.get_default_embedding_model(alias)`, `llm.set_default_embedding_model()`. [#553](https://github.com/simonw/llm/issues/553) +- Support for OpenAI's new [o1 family](https://openai.com/o1/) of preview models, `llm -m o1-preview "prompt"` and `llm -m o1-mini "prompt"`. These models are currently only available to [tier 5](https://platform.openai.com/docs/guides/rate-limits/usage-tiers?context=tier-five) OpenAI API users, though this may change in the future. [#570](https://github.com/simonw/llm/issues/570) + +(v0_15)= +## 0.15 (2024-07-18) + +- Support for OpenAI's [new GPT-4o mini](https://openai.com/index/gpt-4o-mini-advancing-cost-efficient-intelligence/) model: `llm -m gpt-4o-mini 'rave about pelicans in French'` [#536](https://github.com/simonw/llm/issues/536) +- `gpt-4o-mini` is now the default model if you do not {ref}`specify your own default `, replacing GPT-3.5 Turbo. GPT-4o mini is both cheaper and better than GPT-3.5 Turbo. +- Fixed a bug where `llm logs -q 'flourish' -m haiku` could not combine both the `-q` search query and the `-m` model specifier. [#515](https://github.com/simonw/llm/issues/515) + +(v0_14)= +## 0.14 (2024-05-13) + +- Support for OpenAI's [new GPT-4o](https://openai.com/index/hello-gpt-4o/) model: `llm -m gpt-4o 'say hi in Spanish'` [#490](https://github.com/simonw/llm/issues/490) +- The `gpt-4-turbo` alias is now a model ID, which indicates the latest version of OpenAI's GPT-4 Turbo text and image model. Your existing `logs.db` database may contain records under the previous model ID of `gpt-4-turbo-preview`. [#493](https://github.com/simonw/llm/issues/493) +- New `llm logs -r/--response` option for outputting just the last captured response, without wrapping it in Markdown and accompanying it with the prompt. [#431](https://github.com/simonw/llm/issues/431) +- Nine new {ref}`plugins ` since version 0.13: + - **[llm-claude-3](https://github.com/simonw/llm-claude-3)** supporting Anthropic's [Claude 3 family](https://www.anthropic.com/news/claude-3-family) of models. + - **[llm-command-r](https://github.com/simonw/llm-command-r)** supporting Cohere's Command R and [Command R Plus](https://txt.cohere.com/command-r-plus-microsoft-azure/) API models. + - **[llm-reka](https://github.com/simonw/llm-reka)** supports the [Reka](https://www.reka.ai/) family of models via their API. + - **[llm-perplexity](https://github.com/hex/llm-perplexity)** by Alexandru Geana supporting the [Perplexity Labs](https://docs.perplexity.ai/) API models, including `llama-3-sonar-large-32k-online` which can search for things online and `llama-3-70b-instruct`. + - **[llm-groq](https://github.com/angerman/llm-groq)** by Moritz Angermann providing access to fast models hosted by [Groq](https://console.groq.com/docs/models). + - **[llm-fireworks](https://github.com/simonw/llm-fireworks)** supporting models hosted by [Fireworks AI](https://fireworks.ai/). + - **[llm-together](https://github.com/wearedevx/llm-together)** adds support for the [Together AI](https://www.together.ai/) extensive family of hosted openly licensed models. + - **[llm-embed-onnx](https://github.com/simonw/llm-embed-onnx)** provides seven embedding models that can be executed using the ONNX model framework. + - **[llm-cmd](https://github.com/simonw/llm-cmd)** accepts a prompt for a shell command, runs that prompt and populates the result in your shell so you can review it, edit it and then hit `` to execute or `ctrl+c` to cancel, see [this post for details](https://simonwillison.net/2024/Mar/26/llm-cmd/). + +(v0_13_1)= +## 0.13.1 (2024-01-26) + +- Fix for `No module named 'readline'` error on Windows. [#407](https://github.com/simonw/llm/issues/407) + +(v0_13)= +## 0.13 (2024-01-26) + +See also [LLM 0.13: The annotated release notes](https://simonwillison.net/2024/Jan/26/llm/). + +- Added support for new OpenAI embedding models: `3-small` and `3-large` and three variants of those with different dimension sizes, +`3-small-512`, `3-large-256` and `3-large-1024`. See {ref}`OpenAI embedding models ` for details. [#394](https://github.com/simonw/llm/issues/394) +- The default `gpt-4-turbo` model alias now points to `gpt-4-turbo-preview`, which uses the most recent OpenAI GPT-4 turbo model (currently `gpt-4-0125-preview`). [#396](https://github.com/simonw/llm/issues/396) +- New OpenAI model aliases `gpt-4-1106-preview` and `gpt-4-0125-preview`. +- OpenAI models now support a `-o json_object 1` option which will cause their output to be returned as a valid JSON object. [#373](https://github.com/simonw/llm/issues/373) +- New {ref}`plugins ` since the last release include [llm-mistral](https://github.com/simonw/llm-mistral), [llm-gemini](https://github.com/simonw/llm-gemini), [llm-ollama](https://github.com/taketwo/llm-ollama) and [llm-bedrock-meta](https://github.com/flabat/llm-bedrock-meta). +- The `keys.json` file for storing API keys is now created with `600` file permissions. [#351](https://github.com/simonw/llm/issues/351) +- Documented {ref}`a pattern ` for installing plugins that depend on PyTorch using the Homebrew version of LLM, despite Homebrew using Python 3.12 when PyTorch have not yet released a stable package for that Python version. [#397](https://github.com/simonw/llm/issues/397) +- Underlying OpenAI Python library has been upgraded to `>1.0`. It is possible this could cause compatibility issues with LLM plugins that also depend on that library. [#325](https://github.com/simonw/llm/issues/325) +- Arrow keys now work inside the `llm chat` command. [#376](https://github.com/simonw/llm/issues/376) +- `LLM_OPENAI_SHOW_RESPONSES=1` environment variable now outputs much more detailed information about the HTTP request and response made to OpenAI (and OpenAI-compatible) APIs. [#404](https://github.com/simonw/llm/issues/404) +- Dropped support for Python 3.7. + +(v0_12)= +## 0.12 (2023-11-06) + +- Support for the [new GPT-4 Turbo model](https://openai.com/blog/new-models-and-developer-products-announced-at-devday) from OpenAI. Try it using `llm chat -m gpt-4-turbo` or `llm chat -m 4t`. [#323](https://github.com/simonw/llm/issues/323) +- New `-o seed 1` option for OpenAI models which sets a seed that can attempt to evaluate the prompt deterministically. [#324](https://github.com/simonw/llm/issues/324) + +(v0_11_2)= +## 0.11.2 (2023-11-06) + +- Pin to version of OpenAI Python library prior to 1.0 to avoid breaking. [#327](https://github.com/simonw/llm/issues/327) + +(v0_11_1)= +## 0.11.1 (2023-10-31) + +- Fixed a bug where `llm embed -c "text"` did not correctly pick up the configured {ref}`default embedding model `. [#317](https://github.com/simonw/llm/issues/317) +- New plugins: [llm-python](https://github.com/simonw/llm-python), [llm-bedrock-anthropic](https://github.com/sblakey/llm-bedrock-anthropic) and [llm-embed-jina](https://github.com/simonw/llm-embed-jina) (described in [Execute Jina embeddings with a CLI using llm-embed-jina](https://simonwillison.net/2023/Oct/26/llm-embed-jina/)). +- [llm-gpt4all](https://github.com/simonw/llm-gpt4all) now uses the new GGUF model format. [simonw/llm-gpt4all#16](https://github.com/simonw/llm-gpt4all/issues/16) + +(v0_11)= +## 0.11 (2023-09-18) + +LLM now supports the new OpenAI `gpt-3.5-turbo-instruct` model, and OpenAI completion (as opposed to chat completion) models in general. [#284](https://github.com/simonw/llm/issues/284) + +```bash +llm -m gpt-3.5-turbo-instruct 'Reasons to tame a wild beaver:' +``` +OpenAI completion models like this support a `-o logprobs 3` option, which accepts a number between 1 and 5 and will include the log probabilities (for each produced token, what were the top 3 options considered by the model) in the logged response. + +```bash +llm -m gpt-3.5-turbo-instruct 'Say hello succinctly' -o logprobs 3 +``` +You can then view the `logprobs` that were recorded in the SQLite logs database like this: +```bash +sqlite-utils "$(llm logs path)" \ + 'select * from responses order by id desc limit 1' | \ + jq '.[0].response_json' -r | jq +``` +Truncated output looks like this: +``` + [ + { + "text": "Hi", + "top_logprobs": [ + { + "Hi": -0.13706253, + "Hello": -2.3714375, + "Hey": -3.3714373 + } + ] + }, + { + "text": " there", + "top_logprobs": [ + { + " there": -0.96057636, + "!\"": -0.5855763, + ".\"": -3.2574513 + } + ] + } + ] +``` +Also in this release: + +- The `llm.user_dir()` function, used by plugins, now ensures the directory exists before returning it. [#275](https://github.com/simonw/llm/issues/275) +- New `LLM_OPENAI_SHOW_RESPONSES=1` environment variable for displaying the full HTTP response returned by OpenAI compatible APIs. [#286](https://github.com/simonw/llm/issues/286) +- The `llm embed-multi` command now has a `--batch-size X` option for setting the batch size to use when processing embeddings - useful if you have limited memory available. [#273](https://github.com/simonw/llm/issues/273) +- The `collection.embed_multi()` method also now accepts an optional `batch_size=int` argument. +- Fixed two bugs with `llm embed-multi --files` relating to handling of directories. Thanks, [ealvar3z](https://github.com/ealvar3z). [#274](https://github.com/simonw/llm/issues/274), [#280](https://github.com/simonw/llm/issues/280) + +(v0_10)= +## 0.10 (2023-09-12) + +The two major features in this release are the **{ref}`llm chat command `** and support for **embedding binary data**. + +See [Build an image search engine with llm-clip, chat with models with llm chat](https://simonwillison.net/2023/Sep/12/llm-clip-and-chat/) for more background on these features. + +### llm chat + +The new `llm chat` command starts an ongoing chat conversation with a model in your terminal. It works with all models supported by LLM and its {ref}`plugins `, including locally installed models such as Llama 2. [#231](https://github.com/simonw/llm/issues/231) + +This offers a big performance boost for local models, since they don't need to be freshly loaded into memory for each prompt. + +Here's an example chat with Llama 2 13B, provided by the [llm-mlc](https://github.com/simonw/llm-mlc) plugin. + +```bash +llm chat -m mlc-chat-Llama-2-13b-chat-hf-q4f16_1 +``` +This starts a chat session: +``` +Type 'exit' or 'quit' to exit +Type '!multi' to enter multiple lines, then '!end' to finish +Type '!edit' to open your default editor and modify the prompt. +> Who are you? +Hello! I'm just an AI, here to assist you with any questions you may have. +My name is LLaMA, and I'm a large language model trained to provide helpful +and informative responses to a wide range of questions and topics. I'm here +to help you find the information you need, while ensuring a safe and +respectful conversation. Please feel free to ask me anything, and I'll do my +best to provide a helpful and accurate response. +> Tell me a joke about otters +Sure, here's a joke about otters: + +Why did the otter go to the party? + +Because he heard it was a "whale" of a time! + +(Get it? Whale, like a big sea mammal, but also a "wild" or "fun" time. +Otters are known for their playful and social nature, so it's a lighthearted +and silly joke.) + +I hope that brought a smile to your face! Do you have any other questions or +topics you'd like to discuss? +> exit +``` +Chat sessions are {ref}`logged to SQLite ` - use `llm logs` to view them. They can accept system prompts, templates and model options - consult {ref}`the chat documentation ` for details. + +### Binary embedding support + +LLM's {ref}`embeddings feature ` has been expanded to provide support for embedding binary data, in addition to text. [#254](https://github.com/simonw/llm/pull/254) + +This enables models like [CLIP](https://openai.com/research/clip), supported by the new **[llm-clip](https://github.com/simonw/llm-clip)** plugin. + +CLIP is a multi-modal embedding model which can embed images and text into the same vector space. This means you can use it to create an embedding index of photos, and then search for the embedding vector for "a happy dog" and get back images that are semantically closest to that string. + +To create embeddings for every JPEG in a directory stored in a `photos` collection, run: + +```bash +llm install llm-clip +llm embed-multi photos --files photos/ '*.jpg' --binary -m clip +``` +Now you can search for photos of raccoons using: +``` +llm similar photos -c 'raccoon' +``` +This spits out a list of images, ranked by how similar they are to the string "raccoon": +``` +{"id": "IMG_4801.jpeg", "score": 0.28125139257127457, "content": null, "metadata": null} +{"id": "IMG_4656.jpeg", "score": 0.26626441704164294, "content": null, "metadata": null} +{"id": "IMG_2944.jpeg", "score": 0.2647445926996852, "content": null, "metadata": null} +... +``` + +### Also in this release + +- The {ref}`LLM_LOAD_PLUGINS environment variable ` can be used to control which plugins are loaded when `llm` starts running. [#256](https://github.com/simonw/llm/issues/256) +- The `llm plugins --all` option includes builtin plugins in the list of plugins. [#259](https://github.com/simonw/llm/issues/259) +- The `llm embed-db` family of commands has been renamed to `llm collections`. [#229](https://github.com/simonw/llm/issues/229) +- `llm embed-multi --files` now has an `--encoding` option and defaults to falling back to `latin-1` if a file cannot be processed as `utf-8`. [#225](https://github.com/simonw/llm/issues/225) + +(v0_10_a1)= +## 0.10a1 (2023-09-11) + +- Support for embedding binary data. [#254](https://github.com/simonw/llm/pull/254) +- `llm chat` now works for models with API keys. [#247](https://github.com/simonw/llm/issues/247) +- `llm chat -o` for passing options to a model. [#244](https://github.com/simonw/llm/issues/244) +- `llm chat --no-stream` option. [#248](https://github.com/simonw/llm/issues/248) +- `LLM_LOAD_PLUGINS` environment variable. [#256](https://github.com/simonw/llm/issues/256) +- `llm plugins --all` option for including builtin plugins. [#259](https://github.com/simonw/llm/issues/259) +- `llm embed-db` has been renamed to `llm collections`. [#229](https://github.com/simonw/llm/issues/229) +- Fixed bug where `llm embed -c` option was treated as a filepath, not a string. Thanks, [mhalle](https://github.com/mhalle). [#263](https://github.com/simonw/llm/pull/263) + +(v0_10_a0)= +## 0.10a0 (2023-09-04) + +- New {ref}`llm chat ` command for starting an interactive terminal chat with a model. [#231](https://github.com/simonw/llm/issues/231) +- `llm embed-multi --files` now has an `--encoding` option and defaults to falling back to `latin-1` if a file cannot be processed as `utf-8`. [#225](https://github.com/simonw/llm/issues/225) + +(v0_9)= +## 0.9 (2023-09-03) + +The big new feature in this release is support for **embeddings**. See [LLM now provides tools for working with embeddings](https://simonwillison.net/2023/Sep/4/llm-embeddings/) for additional details. + +{ref}`Embedding models ` take a piece of text - a word, sentence, paragraph or even a whole article, and convert that into an array of floating point numbers. [#185](https://github.com/simonw/llm/issues/185) + +This embedding vector can be thought of as representing a position in many-dimensional-space, where the distance between two vectors represents how semantically similar they are to each other within the content of a language model. + +Embeddings can be used to find **related documents**, and also to implement **semantic search** - where a user can search for a phrase and get back results that are semantically similar to that phrase even if they do not share any exact keywords. + +LLM now provides both CLI and Python APIs for working with embeddings. Embedding models are defined by plugins, so you can install additional models using the {ref}`plugins mechanism `. + +The first two embedding models supported by LLM are: + +- OpenAI's [ada-002](https://platform.openai.com/docs/guides/embeddings) embedding model, available via an inexpensive API if you set an OpenAI key using `llm keys set openai`. +- The [sentence-transformers](https://www.sbert.net/) family of models, available via the new [llm-sentence-transformers](https://github.com/simonw/llm-sentence-transformers) plugin. + +See {ref}`embeddings-cli` for detailed instructions on working with embeddings using LLM. + +The new commands for working with embeddings are: + +- **{ref}`llm embed `** - calculate embeddings for content and return them to the console or store them in a SQLite database. +- **{ref}`llm embed-multi `** - run bulk embeddings for multiple strings, using input from a CSV, TSV or JSON file, data from a SQLite database or data found by scanning the filesystem. [#215](https://github.com/simonw/llm/issues/215) +- **{ref}`llm similar `** - run similarity searches against your stored embeddings - starting with a search phrase or finding content related to a previously stored vector. [#190](https://github.com/simonw/llm/issues/190) +- **{ref}`llm embed-models `** - list available embedding models. +- `llm embed-db` - commands for inspecting and working with the default embeddings SQLite database. + +There's also a new {ref}`llm.Collection ` class for creating and searching collections of embedding from Python code, and a {ref}`llm.get_embedding_model() ` interface for embedding strings directly. [#191](https://github.com/simonw/llm/issues/191) + +(v0_8_1)= +## 0.8.1 (2023-08-31) + +- Fixed bug where first prompt would show an error if the `io.datasette.llm` directory had not yet been created. [#193](https://github.com/simonw/llm/issues/193) +- Updated documentation to recommend a different `llm-gpt4all` model since the one we were using is no longer available. [#195](https://github.com/simonw/llm/issues/195) + +(v0_8)= +## 0.8 (2023-08-20) + +- The output format for `llm logs` has changed. Previously it was JSON - it's now a much more readable Markdown format suitable for pasting into other documents. [#160](https://github.com/simonw/llm/issues/160) + - The new `llm logs --json` option can be used to get the old JSON format. + - Pass `llm logs --conversation ID` or `--cid ID` to see the full logs for a specific conversation. +- You can now combine piped input and a prompt in a single command: `cat script.py | llm 'explain this code'`. This works even for models that do not support {ref}`system prompts `. [#153](https://github.com/simonw/llm/issues/153) +- Additional {ref}`openai-compatible-models` can now be configured with custom HTTP headers. This enables platforms such as [openrouter.ai](https://openrouter.ai/) to be used with LLM, which can provide Claude access even without an Anthropic API key. +- Keys set in `keys.json` are now used in preference to environment variables. [#158](https://github.com/simonw/llm/issues/158) +- The documentation now includes a {ref}`plugin directory ` listing all available plugins for LLM. [#173](https://github.com/simonw/llm/issues/173) +- New {ref}`related tools ` section in the documentation describing `ttok`, `strip-tags` and `symbex`. [#111](https://github.com/simonw/llm/issues/111) +- The `llm models`, `llm aliases` and `llm templates` commands now default to running the same command as `llm models list` and `llm aliases list` and `llm templates list`. [#167](https://github.com/simonw/llm/issues/167) +- New `llm keys` (aka `llm keys list`) command for listing the names of all configured keys. [#174](https://github.com/simonw/llm/issues/174) +- Two new Python API functions, `llm.set_alias(alias, model_id)` and `llm.remove_alias(alias)` can be used to configure aliases from within Python code. [#154](https://github.com/simonw/llm/pull/154) +- LLM is now compatible with both Pydantic 1 and Pydantic 2. This means you can install `llm` as a Python dependency in a project that depends on Pydantic 1 without running into dependency conflicts. Thanks, [Chris Mungall](https://github.com/cmungall). [#147](https://github.com/simonw/llm/pull/147) +- `llm.get_model(model_id)` is now documented as raising `llm.UnknownModelError` if the requested model does not exist. [#155](https://github.com/simonw/llm/issues/155) + +(v0_7_1)= +## 0.7.1 (2023-08-19) + +- Fixed a bug where some users would see an `AlterError: No such column: log.id` error when attempting to use this tool, after upgrading to the latest [sqlite-utils 3.35 release](https://sqlite-utils.datasette.io/en/stable/changelog.html#v3-35). [#162](https://github.com/simonw/llm/issues/162) + +(v0_7)= +## 0.7 (2023-08-12) + +The new {ref}`aliases` commands can be used to configure additional aliases for models, for example: + +```bash +llm aliases set turbo gpt-3.5-turbo-16k +``` +Now you can run the 16,000 token `gpt-3.5-turbo-16k` model like this: + +```bash +llm -m turbo 'An epic Greek-style saga about a cheesecake that builds a SQL database from scratch' +``` +Use `llm aliases list` to see a list of aliases and `llm aliases remove turbo` to remove one again. [#151](https://github.com/simonw/llm/issues/151) + +### Notable new plugins + +- **[llm-mlc](https://github.com/simonw/llm-mlc)** can run local models released by the [MLC project](https://mlc.ai/mlc-llm/), including models that can take advantage of the GPU on Apple Silicon M1/M2 devices. +- **[llm-llama-cpp](https://github.com/simonw/llm-llama-cpp)** uses [llama.cpp](https://github.com/ggerganov/llama.cpp) to run models published in the GGML format. See [Run Llama 2 on your own Mac using LLM and Homebrew](https://simonwillison.net/2023/Aug/1/llama-2-mac/) for more details. + +### Also in this release + +- OpenAI models now have min and max validation on their floating point options. Thanks, Pavel Král. [#115](https://github.com/simonw/llm/issues/115) +- Fix for bug where `llm templates list` raised an error if a template had an empty prompt. Thanks, Sherwin Daganato. [#132](https://github.com/simonw/llm/pull/132) +- Fixed bug in `llm install --editable` option which prevented installation of `.[test]`. [#136](https://github.com/simonw/llm/issues/136) +- `llm install --no-cache-dir` and `--force-reinstall` options. [#146](https://github.com/simonw/llm/issues/146) + +(v0_6_1)= +## 0.6.1 (2023-07-24) + +- LLM can now be installed directly from Homebrew core: `brew install llm`. [#124](https://github.com/simonw/llm/issues/124) +- Python API documentation now covers {ref}`python-api-system-prompts`. +- Fixed incorrect example in the {ref}`prompt-templates` documentation. Thanks, Jorge Cabello. [#125](https://github.com/simonw/llm/pull/125) + +(v0_6)= +## 0.6 (2023-07-18) + +- Models hosted on [Replicate](https://replicate.com/) can now be accessed using the [llm-replicate](https://github.com/simonw/llm-replicate) plugin, including the new Llama 2 model from Meta AI. More details here: [Accessing Llama 2 from the command-line with the llm-replicate plugin](https://simonwillison.net/2023/Jul/18/accessing-llama-2/). +- Model providers that expose an API that is compatible with the OpenAPI API format, including self-hosted model servers such as [LocalAI](https://github.com/go-skynet/LocalAI), can now be accessed using {ref}`additional configuration ` for the default OpenAI plugin. [#106](https://github.com/simonw/llm/issues/106) +- OpenAI models that are not yet supported by LLM can also {ref}`be configured ` using the new `extra-openai-models.yaml` configuration file. [#107](https://github.com/simonw/llm/issues/107) +- The {ref}`llm logs command ` now accepts a `-m model_id` option to filter logs to a specific model. Aliases can be used here in addition to model IDs. [#108](https://github.com/simonw/llm/issues/108) +- Logs now have a SQLite full-text search index against their prompts and responses, and the `llm logs -q SEARCH` option can be used to return logs that match a search term. [#109](https://github.com/simonw/llm/issues/109) + +(v0_5)= +## 0.5 (2023-07-12) + +LLM now supports **additional language models**, thanks to a new {ref}`plugins mechanism ` for installing additional models. + +Plugins are available for 19 models in addition to the default OpenAI ones: + +- [llm-gpt4all](https://github.com/simonw/llm-gpt4all) adds support for 17 models that can download and run on your own device, including Vicuna, Falcon and wizardLM. +- [llm-mpt30b](https://github.com/simonw/llm-mpt30b) adds support for the MPT-30B model, a 19GB download. +- [llm-palm](https://github.com/simonw/llm-palm) adds support for Google's PaLM 2 via the Google API. + +A comprehensive tutorial, {ref}`writing a plugin to support a new model ` describes how to add new models by building plugins in detail. + +### New features + +- {ref}`python-api` documentation for using LLM models, including models from plugins, directly from Python. [#75](https://github.com/simonw/llm/issues/75) +- Messages are now logged to the database by default - no need to run the `llm init-db` command any more, which has been removed. Instead, you can toggle this behavior off using `llm logs off` or turn it on again using `llm logs on`. The `llm logs status` command shows the current status of the log database. If logging is turned off, passing `--log` to the `llm prompt` command will cause that prompt to be logged anyway. [#98](https://github.com/simonw/llm/issues/98) +- New database schema for logged messages, with `conversations` and `responses` tables. If you have previously used the old `logs` table it will continue to exist but will no longer be written to. [#91](https://github.com/simonw/llm/issues/91) +- New `-o/--option name value` syntax for setting options for models, such as temperature. Available options differ for different models. [#63](https://github.com/simonw/llm/issues/63) +- `llm models list --options` command for viewing all available model options. [#82](https://github.com/simonw/llm/issues/82) +- `llm "prompt" --save template` option for saving a prompt directly to a template. [#55](https://github.com/simonw/llm/issues/55) +- Prompt templates can now specify {ref}`default values ` for parameters. Thanks, Chris Mungall. [#57](https://github.com/simonw/llm/pull/57) +- `llm openai models` command to list all available OpenAI models from their API. [#70](https://github.com/simonw/llm/issues/70) +- `llm models default MODEL_ID` to set a different model as the default to be used when `llm` is run without the `-m/--model` option. [#31](https://github.com/simonw/llm/issues/31) + +### Smaller improvements + +- `llm -s` is now a shortcut for `llm --system`. [#69](https://github.com/simonw/llm/issues/69) +- `llm -m 4-32k` alias for `gpt-4-32k`. +- `llm install -e directory` command for installing a plugin from a local directory. +- The `LLM_USER_PATH` environment variable now controls the location of the directory in which LLM stores its data. This replaces the old `LLM_KEYS_PATH` and `LLM_LOG_PATH` and `LLM_TEMPLATES_PATH` variables. [#76](https://github.com/simonw/llm/issues/76) +- Documentation covering {ref}`plugin-utilities`. +- Documentation site now uses Plausible for analytics. [#79](https://github.com/simonw/llm/issues/79) + +(v0_4_1)= +## 0.4.1 (2023-06-17) + +- LLM can now be installed using Homebrew: `brew install simonw/llm/llm`. [#50](https://github.com/simonw/llm/issues/50) +- `llm` is now styled LLM in the documentation. [#45](https://github.com/simonw/llm/issues/45) +- Examples in documentation now include a copy button. [#43](https://github.com/simonw/llm/issues/43) +- `llm templates` command no longer has its display disrupted by newlines. [#42](https://github.com/simonw/llm/issues/42) +- `llm templates` command now includes system prompt, if set. [#44](https://github.com/simonw/llm/issues/44) + +(v0_4)= +## 0.4 (2023-06-17) + +This release includes some backwards-incompatible changes: + +- The `-4` option for GPT-4 is now `-m 4`. +- The `--code` option has been removed. +- The `-s` option has been removed as streaming is now the default. Use `--no-stream` to opt out of streaming. + +### Prompt templates + +{ref}`prompt-templates` is a new feature that allows prompts to be saved as templates and re-used with different variables. + +Templates can be created using the `llm templates edit` command: + +```bash +llm templates edit summarize +``` +Templates are YAML - the following template defines summarization using a system prompt: + +```yaml +system: Summarize this text +``` +The template can then be executed like this: +```bash +cat myfile.txt | llm -t summarize +``` +Templates can include both system prompts, regular prompts and indicate the model they should use. They can reference variables such as `$input` for content piped to the tool, or other variables that are passed using the new `-p/--param` option. + +This example adds a `voice` parameter: + +```yaml +system: Summarize this text in the voice of $voice +``` +Then to run it (via [strip-tags](https://github.com/simonw/strip-tags) to remove HTML tags from the input): +```bash +curl -s 'https://til.simonwillison.net/macos/imovie-slides-and-audio' | \ + strip-tags -m | llm -t summarize -p voice GlaDOS +``` +Example output: + +> My previous test subject seemed to have learned something new about iMovie. They exported keynote slides as individual images [...] Quite impressive for a human. + +The {ref}`prompt-templates` documentation provides more detailed examples. + +### Continue previous chat + +You can now use `llm` to continue a previous conversation with the OpenAI chat models (`gpt-3.5-turbo` and `gpt-4`). This will include your previous prompts and responses in the prompt sent to the API, allowing the model to continue within the same context. + +Use the new `-c/--continue` option to continue from the previous message thread: + +```bash +llm "Pretend to be a witty gerbil, say hi briefly" +``` +> Greetings, dear human! I am a clever gerbil, ready to entertain you with my quick wit and endless energy. +```bash +llm "What do you think of snacks?" -c +``` +> Oh, how I adore snacks, dear human! Crunchy carrot sticks, sweet apple slices, and chewy yogurt drops are some of my favorite treats. I could nibble on them all day long! + +The `-c` option will continue from the most recent logged message. + +To continue a different chat, pass an integer ID to the `--chat` option. This should be the ID of a previously logged message. You can find these IDs using the `llm logs` command. + +Thanks [Amjith Ramanujam](https://github.com/amjith) for contributing to this feature. [#6](https://github.com/simonw/llm/issues/6) + +### New mechanism for storing API keys + +API keys for language models such as those by OpenAI can now be saved using the new `llm keys` family of commands. + +To set the default key to be used for the OpenAI APIs, run this: + +```bash +llm keys set openai +``` +Then paste in your API key. + +Keys can also be passed using the new `--key` command line option - this can be a full key or the alias of a key that has been previously stored. + +See {ref}`api-keys` for more. [#13](https://github.com/simonw/llm/issues/13) + +### New location for the logs.db database + +The `logs.db` database that stores a history of executed prompts no longer lives at `~/.llm/log.db` - it can now be found in a location that better fits the host operating system, which can be seen using: + +```bash +llm logs path +``` +On macOS this is `~/Library/Application Support/io.datasette.llm/logs.db`. + +To open that database using Datasette, run this: + +```bash +datasette "$(llm logs path)" +``` +You can upgrade your existing installation by copying your database to the new location like this: +```bash +cp ~/.llm/log.db "$(llm logs path)" +rm -rf ~/.llm # To tidy up the now obsolete directory +``` +The database schema has changed, and will be updated automatically the first time you run the command. + +That schema is [included in the documentation](https://llm.datasette.io/en/stable/logging.html#sql-schema). [#35](https://github.com/simonw/llm/issues/35) + +### Other changes + +- New `llm logs --truncate` option (shortcut `-t`) which truncates the displayed prompts to make the log output easier to read. [#16](https://github.com/simonw/llm/issues/16) +- Documentation now spans multiple pages and lives at [#21](https://github.com/simonw/llm/issues/21) +- Default `llm chatgpt` command has been renamed to `llm prompt`. [#17](https://github.com/simonw/llm/issues/17) +- Removed `--code` option in favour of new prompt templates mechanism. [#24](https://github.com/simonw/llm/issues/24) +- Responses are now streamed by default, if the model supports streaming. The `-s/--stream` option has been removed. A new `--no-stream` option can be used to opt-out of streaming. [#25](https://github.com/simonw/llm/issues/25) +- The `-4/--gpt4` option has been removed in favour of `-m 4` or `-m gpt4`, using a new mechanism that allows models to have additional short names. +- The new `gpt-3.5-turbo-16k` model with a 16,000 token context length can now also be accessed using `-m chatgpt-16k` or `-m 3.5-16k`. Thanks, Benjamin Kirkbride. [#37](https://github.com/simonw/llm/issues/37) +- Improved display of error messages from OpenAI. [#15](https://github.com/simonw/llm/issues/15) + +(v0_3)= +## 0.3 (2023-05-17) + +- `llm logs` command for browsing logs of previously executed completions. [#3](https://github.com/simonw/llm/issues/3) +- `llm "Python code to output factorial 10" --code` option which sets a system prompt designed to encourage code to be output without any additional explanatory text. [#5](https://github.com/simonw/llm/issues/5) +- Tool can now accept a prompt piped directly to standard input. [#11](https://github.com/simonw/llm/issues/11) + +(v0_2)= +## 0.2 (2023-04-01) + +- If a SQLite database exists in `~/.llm/log.db` all prompts and responses are logged to that file. The `llm init-db` command can be used to create this file. [#2](https://github.com/simonw/llm/issues/2) + +(v0_1)= +## 0.1 (2023-04-01) + +- Initial prototype release. [#1](https://github.com/simonw/llm/issues/1) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..f4a2d95 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from subprocess import PIPE, Popen + +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "myst_parser", + "sphinx_copybutton", + "sphinx_markdown_builder", + "sphinx.ext.autodoc", +] +myst_enable_extensions = ["colon_fence"] + +markdown_http_base = "https://llm.datasette.io/en/stable" +markdown_uri_doc_suffix = ".html" + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = ".rst" + +# The master toctree document. +master_doc = "index" + +# General information about the project. +project = "LLM" +copyright = "2025, Simon Willison" +author = "Simon Willison" + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +pipe = Popen("git describe --tags --always", stdout=PIPE, shell=True) +git_version = pipe.stdout.read().decode("utf8") + +if git_version: + version = git_version.rsplit("-", 1)[0] + release = git_version +else: + version = "" + release = "" + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = "en" + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "furo" + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. + +html_theme_options = {} +html_title = "LLM" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = [] + + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = "llm-doc" + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ( + master_doc, + "llm.tex", + "LLM documentation", + "Simon Willison", + "manual", + ) +] + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ( + master_doc, + "llm", + "LLM documentation", + [author], + 1, + ) +] + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + master_doc, + "llm", + "LLM documentation", + author, + "llm", + " Access large language models from the command-line ", + "Miscellaneous", + ) +] diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..63e2e4e --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,66 @@ +(contributing)= +# Contributing + +To contribute to this tool, first checkout the code. Then run the tests with `uv run`: +```bash +cd llm +uv run pytest +``` +You can run your development copy of `llm` using `uv run` as well: +```bash +uv run llm --help +``` + +## Updating recorded HTTP API interactions and associated snapshots + +This project uses [pytest-recording](https://github.com/kiwicom/pytest-recording) to record OpenAI API responses for some of the tests, and [syrupy](https://github.com/syrupy-project/syrupy) to capture snapshots of their results. + +If you add a new test that calls the API you can capture the API response and snapshot like this: +```bash +PYTEST_OPENAI_API_KEY="$(llm keys get openai)" uv run pytest --record-mode once --snapshot-update +``` +Then review the new snapshots in `tests/__snapshots__/` to make sure they look correct. + +## Debugging tricks + +The default OpenAI plugin has a debugging mechanism for showing the exact requests and responses that were sent to the OpenAI API. + +Set the `LLM_OPENAI_SHOW_RESPONSES` environment variable like this: +```bash +LLM_OPENAI_SHOW_RESPONSES=1 uv run llm -m chatgpt 'three word slogan for an otter-run bakery' +``` +This will output details of the API requests and responses to the console. + +Use `--no-stream` to see a more readable version of the body that avoids streaming the response: + +```bash +LLM_OPENAI_SHOW_RESPONSES=1 uv run llm -m chatgpt --no-stream \ + 'three word slogan for an otter-run bakery' +``` + +## Documentation + +Documentation for this project uses [MyST](https://myst-parser.readthedocs.io/) - it is written in Markdown and rendered using Sphinx. + +To build the documentation locally, run the following: +```bash +just docs +``` +This will start a live preview server, using [sphinx-autobuild](https://pypi.org/project/sphinx-autobuild/). + +The CLI `--help` examples in the documentation are managed using [Cog](https://github.com/nedbat/cog). Update those files like this: +```bash +just cog +``` +You'll need [Just](https://github.com/casey/just) installed to run these commands. + +## Release process + +To release a new version: + +1. Update `docs/changelog.md` with the new changes. +2. Update the version number in `pyproject.toml` +3. Run `just cog` to update `docs/fragments.md` with the new version number. +4. [Create a GitHub release](https://github.com/simonw/llm/releases/new) for the new version. +5. Wait for the package to push to PyPI and then... +6. Run the [regenerate.yaml](https://github.com/simonw/homebrew-llm/actions/workflows/regenerate.yaml) workflow to update the Homebrew tap to the latest version. diff --git a/docs/embeddings/cli.md b/docs/embeddings/cli.md new file mode 100644 index 0000000..a6da55e --- /dev/null +++ b/docs/embeddings/cli.md @@ -0,0 +1,443 @@ +(embeddings-cli)= +# Embedding with the CLI + +LLM provides command-line utilities for calculating and storing embeddings for pieces of content. + +(embeddings-cli-embed)= +## llm embed + +The `llm embed` command can be used to calculate embedding vectors for a string of content. These can be returned directly to the terminal, stored in a SQLite database, or both. + +### Returning embeddings to the terminal + +The simplest way to use this command is to pass content to it using the `-c/--content` option, like this: + +```bash +llm embed -c 'This is some content' -m 3-small +``` +`-m 3-small` specifies the OpenAI `text-embedding-3-small` model. You will need to have set an OpenAI API key using `llm keys set openai` for this to work. + +You can install plugins to access other models. The [llm-sentence-transformers](https://github.com/simonw/llm-sentence-transformers) plugin can be used to run models on your own laptop, such as the [MiniLM-L6](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) model: + +```bash +llm install llm-sentence-transformers +llm embed -c 'This is some content' -m sentence-transformers/all-MiniLM-L6-v2 +``` + +The `llm embed` command returns a JSON array of floating point numbers directly to the terminal: + +```json +[0.123, 0.456, 0.789...] +``` +You can omit the `-m/--model` option if you set a {ref}`default embedding model `. + +You can also set the `LLM_EMBEDDING_MODEL` environment variable to set a default model for all `llm embed` commands in the current shell session: + +```bash +export LLM_EMBEDDING_MODEL=3-small +llm embed -c 'This is some content' +``` + +LLM also offers a binary storage format for embeddings, described in {ref}`embeddings storage format `. + +You can output embeddings using that format as raw bytes using `--format blob`, or in hexadecimal using `--format hex`, or in Base64 using `--format base64`: + +```bash +llm embed -c 'This is some content' -m 3-small --format base64 +``` +This outputs: +``` +8NGzPFtdgTqHcZw7aUT6u+++WrwwpZo8XbSxv... +``` +Some models such as [llm-clip](https://github.com/simonw/llm-clip) can run against binary data. You can pass in binary data using the `-i` and `--binary` options: + +```bash +llm embed --binary -m clip -i image.jpg +``` +Or from standard input like this: +```bash +cat image.jpg | llm embed --binary -m clip -i - +``` + +(embeddings-collections)= +### Storing embeddings in SQLite + +Embeddings are much more useful if you store them somewhere, so you can calculate similarity scores between different embeddings later on. + +LLM includes the concept of a **collection** of embeddings. A collection groups together a set of stored embeddings created using the same model, each with a unique ID within that collection. + +Embeddings also store a hash of the content that was embedded. This hash is later used to avoid calculating duplicate embeddings for the same content. + +First, we'll set a default model so we don't have to keep repeating it: +```bash +llm embed-models default 3-small +``` + +The `llm embed` command can store results directly in a named collection like this: + +```bash +llm embed quotations philkarlton-1 -c \ + 'There are only two hard things in Computer Science: cache invalidation and naming things' +``` +This stores the given text in the `quotations` collection under the key `philkarlton-1`. + +You can also pipe content to standard input, like this: +```bash +cat one.txt | llm embed files one +``` +This will store the embedding for the contents of `one.txt` in the `files` collection under the key `one`. + +A collection will be created the first time you mention it. + +Collections have a fixed embedding model, which is the model that was used for the first embedding stored in that collection. + +In the above example this would have been the default embedding model at the time that the command was run. + +The following example stores the embedding for the string "my happy hound" in a collection called `phrases` under the key `hound` and using the model `3-small`: + +```bash +llm embed phrases hound -m 3-small -c 'my happy hound' +``` +By default, the SQLite database used to store embeddings is the `embeddings.db` in the user content directory managed by LLM. + +You can see the path to this directory by running `llm collections path`. + +You can store embeddings in a different SQLite database by passing a path to it using the `-d/--database` option to `llm embed`. If this file does not exist yet the command will create it: + +```bash +llm embed phrases hound -d my-embeddings.db -c 'my happy hound' +``` +This creates a database file called `my-embeddings.db` in the current directory. + +(embeddings-collections-content-metadata)= +#### Storing content and metadata + +By default, only the entry ID and the embedding vector are stored in the database table. + +You can store a copy of the original text in the `content` column by passing the `--store` option: + +```bash +llm embed phrases hound -c 'my happy hound' --store +``` +You can also store a JSON object containing arbitrary metadata in the `metadata` column by passing the `--metadata` option. This example uses both `--store` and `--metadata` options: + +```bash +llm embed phrases hound \ + -m 3-small \ + -c 'my happy hound' \ + --metadata '{"name": "Hound"}' \ + --store +``` +Data stored in this way will be returned by calls to `llm similar`, for example: +```bash +llm similar phrases -c 'hound' +``` +``` +{"id": "hound", "score": 0.8484683588631485, "content": "my happy hound", "metadata": {"name": "Hound"}} +``` + +(embeddings-cli-embed-multi)= +## llm embed-multi + +The `llm embed` command embeds a single string at a time. + +`llm embed-multi` can be used to embed multiple strings at once, taking advantage of any efficiencies that the embedding model may provide when processing multiple strings. + +This command can be called in one of three ways: + +1. With a CSV, TSV, JSON or newline-delimited JSON file +2. With a SQLite database and a SQL query +3. With one or more paths to directories, each accompanied by a glob pattern + +All three mechanisms support these options: + +- `-m model_id` to specify the embedding model to use +- `-d database.db` to specify a different database file to store the embeddings in +- `--store` to store the original content in the embeddings table in addition to the embedding vector +- `--prefix` to prepend a prefix to the stored ID of each item +- `--prepend` to prepend a string to the content before embedding +- `--batch-size SIZE` to process embeddings in batches of the specified size + +The `--prepend` option is useful for embedding models that require you to prepend a special token to the content before embedding it. [nomic-embed-text-v2-moe](https://huggingface.co/nomic-ai/nomic-embed-text-v2-moe) for example requires documents to be prepended `'search_document: '` and search queries to be prepended `'search_query: '`. + +(embeddings-cli-embed-multi-csv-etc)= +### Embedding data from a CSV, TSV or JSON file + +You can embed data from a CSV, TSV or JSON file by passing that file to the command as the second option, after the collection name. + +Your file must contain at least two columns. The first one is expected to contain the ID of the item, and any subsequent columns will be treated as containing content to be embedded. + +An example CSV file might look like this: + +``` +id,content +one,This is the first item +two,This is the second item +``` +TSV would use tabs instead of commas. + +JSON files can be structured like this: + +```json +[ + {"id": "one", "content": "This is the first item"}, + {"id": "two", "content": "This is the second item"} +] +``` +Or as newline-delimited JSON like this: +```json +{"id": "one", "content": "This is the first item"} +{"id": "two", "content": "This is the second item"} +``` +In each of these cases the file can be passed to `llm embed-multi` like this: +```bash +llm embed-multi items mydata.csv +``` +The first argument is the name of the collection, the second is the filename. + +You can also pipe content to standard input of the tool using `-`: + +```bash +cat mydata.json | llm embed-multi items - +``` +LLM will attempt to detect the format of your data automatically. If this doesn't work you can specify the format using the `--format` option. This is required if you are piping newline-delimited JSON to standard input. + +```bash +cat mydata.json | llm embed-multi items - --format nl +``` +Other supported `--format` options are `csv`, `tsv` and `json`. + +This example embeds the data from a JSON file in a collection called `items` in database called `docs.db` using the `3-small` model and stores the original content in the `embeddings` table as well, adding a prefix of `my-items/` to each ID: + +```bash +llm embed-multi items mydata.json \ + -d docs.db \ + -m 3-small \ + --prefix my-items/ \ + --store +``` + +(embeddings-cli-embed-multi-sqlite)= +### Embedding data from a SQLite database + +You can embed data from a SQLite database using `--sql`, optionally combined with `--attach` to attach an additional database. + +If you are storing embeddings in the same database as the source data, you can do this: + +```bash +llm embed-multi docs \ + -d docs.db \ + --sql 'select id, title, content from documents' \ + -m 3-small +``` +The `docs.db` database here contains a `documents` table, and we want to embed the `title` and `content` columns from that table and store the results back in the same database. + +To load content from a database other than the one you are using to store embeddings, attach it with the `--attach` option and use `alias.table` in your SQLite query: + +```bash +llm embed-multi docs \ + -d embeddings.db \ + --attach other other.db \ + --sql 'select id, title, content from other.documents' \ + -m 3-small +``` + +(embeddings-cli-embed-multi-directories)= +### Embedding data from files in directories + +LLM can embed the content of every text file in a specified directory, using the file's path and name as the ID. + +Consider a directory structure like this: +``` +docs/aliases.md +docs/contributing.md +docs/embeddings/binary.md +docs/embeddings/cli.md +docs/embeddings/index.md +docs/index.md +docs/logging.md +docs/plugins/directory.md +docs/plugins/index.md +``` +To embed all of those documents, you can run the following: + +```bash +llm embed-multi documentation \ + -m 3-small \ + --files docs '**/*.md' \ + -d documentation.db \ + --store +``` +Here `--files docs '**/*.md'` specifies that the `docs` directory should be scanned for files matching the `**/*.md` glob pattern - which will match Markdown files in any nested directory. + +The result of the above command is a `embeddings` table with the following IDs: + +``` +aliases.md +contributing.md +embeddings/binary.md +embeddings/cli.md +embeddings/index.md +index.md +logging.md +plugins/directory.md +plugins/index.md +``` +Each corresponding to embedded content for the file in question. + +The `--prefix` option can be used to add a prefix to each ID: + +```bash +llm embed-multi documentation \ + -m 3-small \ + --files docs '**/*.md' \ + -d documentation.db \ + --store \ + --prefix llm-docs/ +``` +This will result in the following IDs instead: + +``` +llm-docs/aliases.md +llm-docs/contributing.md +llm-docs/embeddings/binary.md +llm-docs/embeddings/cli.md +llm-docs/embeddings/index.md +llm-docs/index.md +llm-docs/logging.md +llm-docs/plugins/directory.md +llm-docs/plugins/index.md +``` +Files are assumed to be `utf-8`, but LLM will fall back to `latin-1` if it encounters an encoding error. You can specify a different set of encodings using the `--encoding` option. + +This example will try `utf-16` first and then `mac_roman` before falling back to `latin-1`: +``` +llm embed-multi documentation \ + -m 3-small \ + --files docs '**/*.md' \ + -d documentation.db \ + --encoding utf-16 \ + --encoding mac_roman \ + --encoding latin-1 +``` +If a file cannot be read it will be logged to standard error but the script will keep on running. + +If you are embedding binary content such as images for use with CLIP, add the `--binary` option: +``` +llm embed-multi photos \ + -m clip \ + --files photos/ '*.jpeg' --binary +``` + +(embeddings-cli-similar)= +## llm similar + +The `llm similar` command searches a collection of embeddings for the items that are most similar to a given or item ID, based on [cosine similarity](https://en.wikipedia.org/wiki/Cosine_similarity). + +This currently uses a slow brute-force approach which does not scale well to large collections. See [issue 216](https://github.com/simonw/llm/issues/216) for plans to add a more scalable approach via vector indexes provided by plugins. + +To search the `quotations` collection for items that are semantically similar to `'computer science'`: + +```bash +llm similar quotations -c 'computer science' +``` +This embeds the provided string and returns a newline-delimited list of JSON objects like this: +```json +{"id": "philkarlton-1", "score": 0.8323904531677017, "content": null, "metadata": null} +``` +Use `-p/--plain` to get back results in plain text instead of JSON: +```bash +llm similar quotations -c 'computer science' -p +``` +Example output: +``` +philkarlton-1 (0.8323904531677017) +``` +You can compare against text stored in a file using `-i filename`: +```bash +llm similar quotations -i one.txt +``` +Or feed text to standard input using `-i -`: +```bash +echo 'computer science' | llm similar quotations -i - +``` +When using a model like CLIP, you can find images similar to an input image using `-i filename` with `--binary`: +```bash +llm similar photos -i image.jpg --binary +``` + +You can filter results to only show IDs that begin with a specific prefix using --prefix: + +```bash +llm similar quotations --prefix 'movies/' -c 'star wars' +``` + +(embeddings-cli-embed-models)= +## llm embed-models + +To list all available embedding models, including those provided by plugins, run this command: + +```bash +llm embed-models +``` +The output should look something like this: +``` +OpenAIEmbeddingModel: text-embedding-ada-002 (aliases: ada, ada-002) +OpenAIEmbeddingModel: text-embedding-3-small (aliases: 3-small) +OpenAIEmbeddingModel: text-embedding-3-large (aliases: 3-large) +... +``` +Add `-q` one or more times to search for models matching those terms: +```bash +llm embed-models -q 3-small +``` + +(embeddings-cli-embed-models-default)= +### llm embed-models default + +This command can be used to get and set the default embedding model. + +This will return the name of the current default model: +```bash +llm embed-models default +``` +You can set a different default like this: +```bash +llm embed-models default 3-small +``` +This will set the default model to OpenAI's `3-small` model. + +Any of the supported aliases for a model can be passed to this command. + +You can unset the default model using `--remove-default`: + +```bash +llm embed-models default --remove-default +``` +When no default model is set, the `llm embed` and `llm embed-multi` commands will require that a model is specified using `-m/--model`. + +## llm collections list + +To list all of the collections in the embeddings database, run this command: + +```bash +llm collections list +``` +Add `--json` for JSON output: +```bash +llm collections list --json +``` +Add `-d/--database` to specify a different database file: +```bash +llm collections list -d my-embeddings.db +``` +## llm collections delete + +To delete a collection from the database, run this: +```bash +llm collections delete collection-name +``` +Pass `-d` to specify a different database file: +```bash +llm collections delete collection-name -d my-embeddings.db +``` diff --git a/docs/embeddings/index.md b/docs/embeddings/index.md new file mode 100644 index 0000000..b150cab --- /dev/null +++ b/docs/embeddings/index.md @@ -0,0 +1,26 @@ +(embeddings)= +# Embeddings + +Embedding models allow you to take a piece of text - a word, sentence, paragraph or even a whole article, and convert that into an array of floating point numbers. + +This floating point array is called an "embedding vector", and works as a numerical representation of the semantic meaning of the content in a many-multi-dimensional space. + +By calculating the distance between embedding vectors, we can identify which content is semantically "nearest" to other content. + +This can be used to build features like related article lookups. It can also be used to build semantic search, where a user can search for a phrase and get back results that are semantically similar to that phrase even if they do not share any exact keywords. + +Some embedding models like [CLIP](https://github.com/simonw/llm-clip) can even work against binary files such as images. These can be used to search for images that are similar to other images, or to search for images that are semantically similar to a piece of text. + +LLM supports multiple embedding models through {ref}`plugins `. Once installed, an embedding model can be used on the command-line or via the Python API to calculate and store embeddings for content, and then to perform similarity searches against those embeddings. + +See [LLM now provides tools for working with embeddings](https://simonwillison.net/2023/Sep/4/llm-embeddings/) for an extended explanation of embeddings, why they are useful and what you can do with them. + +```{toctree} +--- +maxdepth: 3 +--- +cli +python-api +writing-plugins +storage +``` diff --git a/docs/embeddings/python-api.md b/docs/embeddings/python-api.md new file mode 100644 index 0000000..c6ec44e --- /dev/null +++ b/docs/embeddings/python-api.md @@ -0,0 +1,211 @@ +(embeddings-python-api)= +# Using embeddings from Python + +You can load an embedding model using its model ID or alias like this: +```python +import llm + +embedding_model = llm.get_embedding_model("3-small") +``` +To embed a string, returning a Python list of floating point numbers, use the `.embed()` method: +```python +vector = embedding_model.embed("my happy hound") +``` +If the embedding model can handle binary input, you can call `.embed()` with a byte string instead. You can check the `supports_binary` property to see if this is supported: +```python +if embedding_model.supports_binary: + vector = embedding_model.embed(open("my-image.jpg", "rb").read()) +``` +The `embedding_model.supports_text` property indicates if the model supports text input. + +Many embeddings models are more efficient when you embed multiple strings or binary strings at once. To embed multiple strings at once, use the `.embed_multi()` method: +```python +vectors = list(embedding_model.embed_multi(["my happy hound", "my dissatisfied cat"])) +``` +This returns a generator that yields one embedding vector per string. + +Embeddings are calculated in batches. By default all items will be processed in a single batch, unless the underlying embedding model has defined its own preferred batch size. You can pass a custom batch size using `batch_size=N`, for example: + +```python +vectors = list(embedding_model.embed_multi(lines_from_file, batch_size=20)) +``` + +(embeddings-python-collections)= +## Working with collections + +The `llm.Collection` class can be used to work with **collections** of embeddings from Python code. + +A collection is a named group of embedding vectors, each stored along with their IDs in a SQLite database table. + +To work with embeddings in this way you will need an instance of a [sqlite-utils Database](https://sqlite-utils.datasette.io/en/stable/python-api.html#connecting-to-or-creating-a-database) object. You can then pass that to the `llm.Collection` constructor along with the unique string name of the collection and the ID of the embedding model you will be using with that collection: + +```python +import sqlite_utils +import llm + +# This collection will use an in-memory database that will be +# discarded when the Python process exits +collection = llm.Collection("entries", model_id="3-small") + +# Or you can persist the database to disk like this: +db = sqlite_utils.Database("my-embeddings.db") +collection = llm.Collection("entries", db, model_id="3-small") + +# You can pass a model directly using model= instead of model_id= +embedding_model = llm.get_embedding_model("3-small") +collection = llm.Collection("entries", db, model=embedding_model) +``` +If the collection already exists in the database you can omit the `model` or `model_id` argument - the model ID will be read from the `collections` table. + +To embed a single string and store it in the collection, use the `embed()` method: + +```python +collection.embed("hound", "my happy hound") +``` +This stores the embedding for the string "my happy hound" in the `entries` collection under the key `hound`. + +Add `store=True` to store the text content itself in the database table along with the embedding vector. + +To attach additional metadata to an item, pass a JSON-compatible dictionary as the `metadata=` argument: + +```python +collection.embed("hound", "my happy hound", metadata={"name": "Hound"}, store=True) +``` +This additional metadata will be stored as JSON in the `metadata` column of the embeddings database table. + +(embeddings-python-bulk)= +### Storing embeddings in bulk + +The `collection.embed_multi()` method can be used to store embeddings for multiple items at once. This can be more efficient for some embedding models. + +```python +collection.embed_multi( + [ + ("hound", "my happy hound"), + ("cat", "my dissatisfied cat"), + ], + # Add this to store the strings in the content column: + store=True, +) +``` +To include metadata to be stored with each item, call `embed_multi_with_metadata()`: + +```python +collection.embed_multi_with_metadata( + [ + ("hound", "my happy hound", {"name": "Hound"}), + ("cat", "my dissatisfied cat", {"name": "Cat"}), + ], + # This can also take the store=True argument: + store=True, +) +``` +The `batch_size=` argument defaults to 100, and will be used unless the embedding model itself defines a lower batch size. You can adjust this if you are having trouble with memory while embedding large collections: + +```python +collection.embed_multi( + ( + (i, line) + for i, line in enumerate(lines_in_file) + ), + batch_size=10 +) +``` + +(embeddings-python-collection-class)= +### Collection class reference + +A collection instance has the following properties and methods: + +- `id` - the integer ID of the collection in the database +- `name` - the string name of the collection (unique in the database) +- `model_id` - the string ID of the embedding model used for this collection +- `model()` - returns the `EmbeddingModel` instance, based on that `model_id` +- `count()` - returns the integer number of items in the collection +- `embed(id: str, text: str, metadata: dict=None, store: bool=False)` - embeds the given string and stores it in the collection under the given ID. Can optionally include metadata (stored as JSON) and store the text content itself in the database table. +- `embed_multi(entries: Iterable, store: bool=False, batch_size: int=100)` - see above +- `embed_multi_with_metadata(entries: Iterable, store: bool=False, batch_size: int=100)` - see above +- `similar(query: str, number: int=10)` - returns a list of entries that are most similar to the embedding of the given query string +- `similar_by_id(id: str, number: int=10)` - returns a list of entries that are most similar to the embedding of the item with the given ID +- `similar_by_vector(vector: List[float], number: int=10, skip_id: str=None)` - returns a list of entries that are most similar to the given embedding vector, optionally skipping the entry with the given ID +- `delete()` - deletes the collection and its embeddings from the database + +There is also a `Collection.exists(db, name)` class method which returns a boolean value and can be used to determine if a collection exists or not in a database: + +```python +if Collection.exists(db, "entries"): + print("The entries collection exists") +``` + +(embeddings-python-similar)= +## Retrieving similar items + +Once you have populated a collection of embeddings you can retrieve the entries that are most similar to a given string using the `similar()` method. + +This method uses a brute force approach, calculating distance scores against every document. This is fine for small collections, but will not scale to large collections. See [issue 216](https://github.com/simonw/llm/issues/216) for plans to add a more scalable approach via vector indexes provided by plugins. + +```python +for entry in collection.similar("hound"): + print(entry.id, entry.score) +``` +The string will first by embedded using the model for the collection. + +The `entry` object returned is an object with the following properties: + +- `id` - the string ID of the item +- `score` - the floating point similarity score between the item and the query string +- `content` - the string text content of the item, if it was stored - or `None` +- `metadata` - the dictionary (from JSON) metadata for the item, if it was stored - or `None` + +This defaults to returning the 10 most similar items. You can change this by passing a different `number=` argument: +```python +for entry in collection.similar("hound", number=5): + print(entry.id, entry.score) +``` +The `similar_by_id()` method takes the ID of another item in the collection and returns the most similar items to that one, based on the embedding that has already been stored for it: + +```python +for entry in collection.similar_by_id("cat"): + print(entry.id, entry.score) +``` +The item itself is excluded from the results. + +(embeddings-sql-schema)= +## SQL schema + +Here's the SQL schema used by the embeddings database: + + +```sql +CREATE TABLE "collections" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT, + "model" TEXT +) +CREATE TABLE "embeddings" ( + "collection_id" INTEGER REFERENCES "collections"("id"), + "id" TEXT, + "embedding" BLOB, + "content" TEXT, + "content_blob" BLOB, + "content_hash" BLOB, + "metadata" TEXT, + "updated" INTEGER, + PRIMARY KEY ("collection_id", "id") +) +``` + diff --git a/docs/embeddings/storage.md b/docs/embeddings/storage.md new file mode 100644 index 0000000..f932b47 --- /dev/null +++ b/docs/embeddings/storage.md @@ -0,0 +1,31 @@ +(embeddings-storage)= +# Embedding storage format + +The default output format of the `llm embed` command is a JSON array of floating point numbers. + +LLM stores embeddings in space-efficient format: a little-endian binary sequences of 32-bit floating point numbers, each represented using 4 bytes. + +These are stored in a `BLOB` column in a SQLite database. + +The following Python functions can be used to convert between this format and an array of floating point numbers: + +```python +import struct + +def encode(values): + return struct.pack("<" + "f" * len(values), *values) + +def decode(binary): + return struct.unpack("<" + "f" * (len(binary) // 4), binary) +``` + +These functions are available as `llm.encode()` and `llm.decode()`. + +If you are using [NumPy](https://numpy.org/) you can decode one of these binary values like this: + +```python +import numpy as np + +numpy_array = np.frombuffer(value, "` for details on how to develop and package a plugin. + +This page shows an example plugin that implements and registers a new embedding model. + +There are two components to an embedding model plugin: + +1. An implementation of the `register_embedding_models()` hook, which takes a `register` callback function and calls it to register the new model with the LLM plugin system. +2. A class that extends the `llm.EmbeddingModel` abstract base class. + + The only required method on this class is `embed_batch(texts)`, which takes an iterable of strings and returns an iterator over lists of floating point numbers. + +The following example uses the [sentence-transformers](https://github.com/UKPLab/sentence-transformers) package to provide access to the [MiniLM-L6](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) embedding model. + +```{eval-rst} +.. autoclass:: llm.EmbeddingModel + :members: embed, embed_multi, embed_batch +``` + +```python +import llm +from sentence_transformers import SentenceTransformer + + +@llm.hookimpl +def register_embedding_models(register): + model_id = "sentence-transformers/all-MiniLM-L6-v2" + register(SentenceTransformerModel(model_id, model_id), aliases=("all-MiniLM-L6-v2",)) + + +class SentenceTransformerModel(llm.EmbeddingModel): + def __init__(self, model_id, model_name): + self.model_id = model_id + self.model_name = model_name + self._model = None + + def embed_batch(self, texts): + if self._model is None: + self._model = SentenceTransformer(self.model_name) + results = self._model.encode(texts) + return (list(map(float, result)) for result in results) +``` +Once installed, the model provided by this plugin can be used with the {ref}`llm embed ` command like this: + +```bash +cat file.txt | llm embed -m sentence-transformers/all-MiniLM-L6-v2 +``` +Or via its registered alias like this: +```bash +cat file.txt | llm embed -m all-MiniLM-L6-v2 +``` +[llm-sentence-transformers](https://github.com/simonw/llm-sentence-transformers) is a complete example of a plugin that provides an embedding model. + +[Execute Jina embeddings with a CLI using llm-embed-jina](https://simonwillison.net/2023/Oct/26/llm-embed-jina/#how-i-built-the-plugin) talks through a similar process to add support for the [Jina embeddings models](https://jina.ai/news/jina-ai-launches-worlds-first-open-source-8k-text-embedding-rivaling-openai/). + +## Embedding binary content + +If your model can embed binary content, use the `supports_binary` property to indicate that: + +```python +class ClipEmbeddingModel(llm.EmbeddingModel): + model_id = "clip" + supports_binary = True + supports_text= True +``` + +`supports_text` defaults to `True` and so is not necessary here. You can set it to `False` if your model only supports binary data. + +If your model accepts binary, your `.embed_batch()` model may be called with a list of Python bytestrings. These may be mixed with regular strings if the model accepts both types of input. + +[llm-clip](https://github.com/simonw/llm-clip) is an example of a model that can embed both binary and text content. diff --git a/docs/fragments.md b/docs/fragments.md new file mode 100644 index 0000000..ac29b72 --- /dev/null +++ b/docs/fragments.md @@ -0,0 +1,217 @@ +(fragments)= +# Fragments + +LLM prompts can optionally be composed out of **fragments** - reusable pieces of text that are logged just once to the database and can then be attached to multiple prompts. + +These are particularly useful when you are working with long context models, which support feeding large amounts of text in as part of your prompt. + +Fragments primarily exist to save space in the database, but may be used to support other features such as vendor prompt caching as well. + +Fragments can be specified using several different mechanisms: + +- URLs to text files online +- Paths to text files on disk +- Aliases that have been attached to a specific fragment +- Hash IDs of stored fragments, where the ID is the SHA256 hash of the fragment content +- Fragments that are provided by custom plugins - these look like `plugin-name:argument` + +(fragments-usage)= +## Using fragments in a prompt + +Use the `-f/--fragment` option to specify one or more fragments to be used as part of your prompt: + +```bash +llm -f https://llm.datasette.io/robots.txt "Explain this robots.txt file in detail" +``` +Here we are specifying a fragment using a URL. The contents of that URL will be included in the prompt that is sent to the model, prepended prior to the prompt text. + + +The URL will be fetched with the user-agent `llm/0.32a3 (https://llm.datasette.io/)`. + + +The `-f` option can be used multiple times to combine together multiple fragments. + +Fragments can also be files on disk, for example: +```bash +llm -f setup.py 'extract the metadata' +``` +Use `-` to specify a fragment that is read from standard input: +```bash +llm -f - 'extract the metadata' < setup.py +``` +This will read the contents of `setup.py` from standard input and use it as a fragment. + +Fragments can also be used as part of your system prompt. Use `--sf value` or `--system-fragment value` instead of `-f`. + +## Using fragments in chat + +The `chat` command also supports the `-f` and `--sf` arguments to start a chat with fragments. + +```bash +llm chat -f my_doc.txt +Chatting with gpt-4 +Type 'exit' or 'quit' to exit +Type '!multi' to enter multiple lines, then '!end' to finish +Type '!edit' to open your default editor and modify the prompt. +Type '!fragment [ ...]' to insert one or more fragments +> Explain this document to me +``` + +Fragments can also be added *during* a chat conversation using the `!fragment ` command. + +```bash +Chatting with gpt-4 +Type 'exit' or 'quit' to exit +Type '!multi' to enter multiple lines, then '!end' to finish +Type '!edit' to open your default editor and modify the prompt. +Type '!fragment [ ...]' to insert one or more fragments +> !fragment https://llm.datasette.io/en/stable/fragments.html +``` + +This can be combined with `!multi`: + +```bash +> !multi +Explain the difference between fragments and templates to me +!fragment https://llm.datasette.io/en/stable/fragments.html https://llm.datasette.io/en/stable/templates.html +!end +``` + +Any `!fragment` lines found in a prompt created with `!edit` will not be parsed. + +(fragments-browsing)= +## Browsing fragments + +You can view a truncated version of the fragments you have previously stored in your database with the `llm fragments` command: + +```bash +llm fragments +``` +The output from that command looks like this: + +```yaml +- hash: 0d6e368f9bc21f8db78c01e192ecf925841a957d8b991f5bf9f6239aa4d81815 + aliases: [] + datetime_utc: '2025-04-06 07:36:53' + source: https://raw.githubusercontent.com/simonw/llm-docs/refs/heads/main/llm/0.22.txt + content: |- + + + docs/aliases.md + + (aliases)= + #... +- hash: 16b686067375182573e2aa16b5bfc1e64d48350232535d06444537e51f1fd60c + aliases: [] + datetime_utc: '2025-04-06 23:03:47' + source: simonw/files-to-prompt/pyproject.toml + content: |- + [project] + name = "files-to-prompt" + version = "0.6" + description = "Concatenate a directory full of... +``` +Those long `hash` values are IDs that can be used to reference a fragment in the future: +```bash +llm -f 16b686067375182573e2aa16b5bfc1e64d48350232535d06444537e51f1fd60c 'Extract metadata' +``` +Use `-q searchterm` one or more times to search for fragments that match a specific set of search terms. + +To view the full content of a fragment use `llm fragments show`: +```bash +llm fragments show 0d6e368f9bc21f8db78c01e192ecf925841a957d8b991f5bf9f6239aa4d81815 +``` + +(fragments-aliases)= +## Setting aliases for fragments + +You can assign aliases to fragments that you use often using the `llm fragments set` command: +```bash +llm fragments set mydocs ./docs.md +``` +To remove an alias, use `llm fragments remove`: +```bash +llm fragments remove mydocs +``` +You can then use that alias in place of the fragment hash ID: +```bash +llm -f mydocs 'How do I access metadata?' +``` +Use `llm fragments --aliases` to see a full list of fragments that have been assigned aliases: +```bash +llm fragments --aliases +``` + +(fragments-logs)= +## Viewing fragments in your logs + +The `llm logs` command lists the fragments that were used for a prompt. By default these are listed as fragment hash IDs, but you can use the `--expand` option to show the full content of each fragment. + +This command will show the expanded fragments for your most recent conversation: + +```bash +llm logs -c --expand +``` +You can filter for logs that used a specific fragment using the `-f/--fragment` option: +```bash +llm logs -c -f 0d6e368f9bc21f8db78c01e192ecf925841a957d8b991f5bf9f6239aa4d81815 +``` +This accepts URLs, file paths, aliases, and hash IDs. + +Multiple `-f` options will return responses that used **all** of the specified fragments. + +Fragments are returned by `llm logs --json` as well. By default these are truncated but you can add the `-e/--expand` option to show the full content of each fragment. + +```bash +llm logs -c --json --expand +``` + +(fragments-plugins)= +## Using fragments from plugins + +LLM plugins can provide custom fragment loaders which do useful things. + +One example is the [llm-fragments-github plugin](https://github.com/simonw/llm-fragments-github). This can convert the files from a public GitHub repository into a list of fragments, allowing you to ask questions about the full repository. + +Here's how to try that out: + +```bash +llm install llm-fragments-github +llm -f github:simonw/s3-credentials 'Suggest new features for this tool' +``` +This plugin turns a single call to `-f github:simonw/s3-credentials` into multiple fragments, one for every text file in the [simonw/s3-credentials](https://github.com/simonw/s3-credentials) GitHub repository. + +Running `llm logs -c` will show that this prompt incorporated 26 fragments, one for each file. + +Running `llm logs -c --usage --expand` (shortcut: `llm logs -cue`) includes token usage information and turns each fragment ID into a full copy of that file. [Here's the output of that command](https://gist.github.com/simonw/c9bbbc5f6560b01f4b7882ac0194fb25). + +Fragment plugins can return {ref}`attachments ` (such as images) as well. + +See the {ref}`register_fragment_loaders() plugin hook ` documentation for details on writing your own custom fragment plugin. + +(fragments-loaders)= +## Listing available fragment prefixes + +The `llm fragments loaders` command shows all prefixes that have been installed by plugins, along with their documentation: + +```bash +llm install llm-fragments-github +llm fragments loaders +``` +Example output: +``` +github: + Load files from a GitHub repository as fragments + + Argument is a GitHub repository URL or username/repository + +issue: + Fetch GitHub issue and comments as Markdown + + Argument is either "owner/repo/NUMBER" + or "https://github.com/owner/repo/issues/NUMBER" +``` diff --git a/docs/help.md b/docs/help.md new file mode 100644 index 0000000..0503832 --- /dev/null +++ b/docs/help.md @@ -0,0 +1,1088 @@ +# CLI reference + +This page lists the `--help` output for all of the `llm` commands. + + + +(help)= +## llm --help +``` +Usage: llm [OPTIONS] COMMAND [ARGS]... + + 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: + + $ 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 + +Options: + --version Show the version and exit. + -h, --help Show this message and exit. + +Commands: + prompt* Execute a prompt + aliases Manage model aliases + chat Hold an ongoing chat with a model. + collections View and manage collections of embeddings + embed Embed text and store or return the result + embed-models Manage available embedding models + embed-multi Store embeddings for multiple strings at once in the... + fragments Manage fragments that are stored in the database + install Install packages from PyPI into the same environment as LLM + keys Manage stored API keys for different models + logs Tools for exploring logged prompts and responses + models Manage available models + openai Commands for working directly with the OpenAI API + plugins List installed plugins + schemas Manage stored schemas + similar Return top N similar IDs from a collection using cosine... + templates Manage stored prompt templates + tools Manage tools that can be made available to LLMs + uninstall Uninstall Python packages from the LLM environment +``` + +(help-prompt)= +### llm prompt --help +``` +Usage: llm prompt [OPTIONS] [PROMPT] + + Execute a prompt + + Documentation: https://llm.datasette.io/en/stable/usage.html + + Examples: + + 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: + + 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. + + llm 'JavaScript function for reversing a string' -x + +Options: + -s, --system TEXT System prompt to use + -m, --model TEXT Model to use + -d, --database FILE Path to log database + -q, --query TEXT Use first model matching these strings + -a, --attachment ATTACHMENT Attachment path or URL or - + --at, --attachment-type ... + Attachment with explicit mimetype, + --at image.jpg image/jpeg + -T, --tool TEXT Name of a tool to make available to the model + --functions TEXT Python code block or file path defining + functions to register as tools + --td, --tools-debug Show full details of tool executions + --ta, --tools-approve Manually approve every tool execution + --cl, --chain-limit INTEGER How many chained tool responses to allow, + default 5, set 0 for unlimited + -o, --option ... key/value options for the model + --options Show options for the selected model + --schema TEXT JSON schema, filepath or ID + --schema-multi TEXT JSON schema to use for multiple results + -f, --fragment TEXT Fragment (alias, URL, hash or file path) to + add to the prompt + --sf, --system-fragment TEXT Fragment to add to system prompt + -t, --template TEXT Template to use + -p, --param ... Parameters for template + --no-stream Do not stream output + -n, --no-log Don't log to database + --log Log prompt and response to the database + -R, --hide-reasoning Hide reasoning output + -c, --continue Continue the most recent conversation. + --cid, --conversation TEXT Continue the conversation with the given ID. + --key TEXT API key to use + --save TEXT Save prompt with this template name + --async Run prompt asynchronously + -u, --usage Show token usage + -x, --extract Extract first fenced code block + --xl, --extract-last Extract last fenced code block + -h, --help Show this message and exit. +``` + +(help-chat)= +### llm chat --help +``` +Usage: llm chat [OPTIONS] + + Hold an ongoing chat with a model. + +Options: + -s, --system TEXT System prompt to use + -m, --model TEXT Model to use + -c, --continue Continue the most recent conversation. + --cid, --conversation TEXT Continue the conversation with the given ID. + -f, --fragment TEXT Fragment (alias, URL, hash or file path) to add + to the prompt + --sf, --system-fragment TEXT Fragment to add to system prompt + -t, --template TEXT Template to use + -p, --param ... Parameters for template + -o, --option ... key/value options for the model + -d, --database FILE Path to log database + --no-stream Do not stream output + -R, --hide-reasoning Hide reasoning output + --key TEXT API key to use + -T, --tool TEXT Name of a tool to make available to the model + --functions TEXT Python code block or file path defining + functions to register as tools + --td, --tools-debug Show full details of tool executions + --ta, --tools-approve Manually approve every tool execution + --cl, --chain-limit INTEGER How many chained tool responses to allow, + default 5, set 0 for unlimited + -h, --help Show this message and exit. +``` + +(help-keys)= +### llm keys --help +``` +Usage: llm keys [OPTIONS] COMMAND [ARGS]... + + Manage stored API keys for different models + +Options: + -h, --help Show this message and exit. + +Commands: + list* List names of all stored keys + get Return the value of a stored key + path Output the path to the keys.json file + set Save a key in the keys.json file +``` + +(help-keys-list)= +#### llm keys list --help +``` +Usage: llm keys list [OPTIONS] + + List names of all stored keys + +Options: + -h, --help Show this message and exit. +``` + +(help-keys-path)= +#### llm keys path --help +``` +Usage: llm keys path [OPTIONS] + + Output the path to the keys.json file + +Options: + -h, --help Show this message and exit. +``` + +(help-keys-get)= +#### llm keys get --help +``` +Usage: llm keys get [OPTIONS] NAME + + Return the value of a stored key + + Example usage: + + export OPENAI_API_KEY=$(llm keys get openai) + +Options: + -h, --help Show this message and exit. +``` + +(help-keys-set)= +#### llm keys set --help +``` +Usage: llm keys set [OPTIONS] NAME + + Save a key in the keys.json file + + Example usage: + + $ llm keys set openai + Enter key: ... + +Options: + --value TEXT Value to set + -h, --help Show this message and exit. +``` + +(help-logs)= +### llm logs --help +``` +Usage: llm logs [OPTIONS] COMMAND [ARGS]... + + Tools for exploring logged prompts and responses + +Options: + -h, --help Show this message and exit. + +Commands: + list* Show logged prompts and their responses + backup Backup your logs database to this file + off Turn off logging for all prompts + on Turn on logging for all prompts + path Output the path to the logs.db file + status Show current status of database logging +``` + +(help-logs-path)= +#### llm logs path --help +``` +Usage: llm logs path [OPTIONS] + + Output the path to the logs.db file + +Options: + -h, --help Show this message and exit. +``` + +(help-logs-status)= +#### llm logs status --help +``` +Usage: llm logs status [OPTIONS] + + Show current status of database logging + +Options: + -h, --help Show this message and exit. +``` + +(help-logs-backup)= +#### llm logs backup --help +``` +Usage: llm logs backup [OPTIONS] PATH + + Backup your logs database to this file + +Options: + -h, --help Show this message and exit. +``` + +(help-logs-on)= +#### llm logs on --help +``` +Usage: llm logs on [OPTIONS] + + Turn on logging for all prompts + +Options: + -h, --help Show this message and exit. +``` + +(help-logs-off)= +#### llm logs off --help +``` +Usage: llm logs off [OPTIONS] + + Turn off logging for all prompts + +Options: + -h, --help Show this message and exit. +``` + +(help-logs-list)= +#### llm logs list --help +``` +Usage: llm logs list [OPTIONS] + + Show logged prompts and their responses + +Options: + -n, --count INTEGER Number of entries to show - defaults to 3, use 0 + for all + -d, --database FILE Path to log database + -m, --model TEXT Filter by model or model alias + -q, --query TEXT Search for logs matching this string + -f, --fragment TEXT Filter for prompts using these fragments + -T, --tool TEXT Filter for prompts with results from these tools + --tools Filter for prompts with results from any tools + --schema TEXT JSON schema, filepath or ID + --schema-multi TEXT JSON schema used for multiple results + -l, --latest Return latest results matching search query + --data Output newline-delimited JSON data for schema + --data-array Output JSON array of data for schema + --data-key TEXT Return JSON objects from array in this key + --data-ids Attach corresponding IDs to JSON objects + -t, --truncate Truncate long strings in output + -s, --short Shorter YAML output with truncated prompts + -u, --usage Include token usage + -r, --response Just output the last response + -x, --extract Extract first fenced code block + --xl, --extract-last Extract last fenced code block + -c, --current Show logs from the current conversation + --cid, --conversation TEXT Show logs for this conversation ID + --id-gt TEXT Return responses with ID > this + --id-gte TEXT Return responses with ID >= this + --json Output logs as JSON + -e, --expand Expand fragments to show their content + -h, --help Show this message and exit. +``` + +(help-models)= +### llm models --help +``` +Usage: llm models [OPTIONS] COMMAND [ARGS]... + + Manage available models + +Options: + -h, --help Show this message and exit. + +Commands: + list* List available models + default Show or set the default model + options Manage default options for models +``` + +(help-models-list)= +#### llm models list --help +``` +Usage: llm models list [OPTIONS] + + List available models + +Options: + --options Show options for each model, if available + --async List async models + --schemas List models that support schemas + --tools List models that support tools + -q, --query TEXT Search for models matching these strings + -m, --model TEXT Specific model IDs + -h, --help Show this message and exit. +``` + +(help-models-default)= +#### llm models default --help +``` +Usage: llm models default [OPTIONS] [MODEL] + + Show or set the default model + +Options: + -h, --help Show this message and exit. +``` + +(help-models-options)= +#### llm models options --help +``` +Usage: llm models options [OPTIONS] COMMAND [ARGS]... + + Manage default options for models + +Options: + -h, --help Show this message and exit. + +Commands: + list* List default options for all models + clear Clear default option(s) for a model + set Set a default option for a model + show List default options set for a specific model +``` + +(help-models-options-list)= +##### llm models options list --help +``` +Usage: llm models options list [OPTIONS] + + List default options for all models + + Example usage: + + llm models options list + +Options: + -h, --help Show this message and exit. +``` + +(help-models-options-show)= +##### llm models options show --help +``` +Usage: llm models options show [OPTIONS] MODEL + + List default options set for a specific model + + Example usage: + + llm models options show gpt-4o + +Options: + -h, --help Show this message and exit. +``` + +(help-models-options-set)= +##### llm models options set --help +``` +Usage: llm models options set [OPTIONS] MODEL KEY VALUE + + Set a default option for a model + + Example usage: + + llm models options set gpt-4o temperature 0.5 + +Options: + -h, --help Show this message and exit. +``` + +(help-models-options-clear)= +##### llm models options clear --help +``` +Usage: llm models options clear [OPTIONS] MODEL [KEY] + + Clear default option(s) for a model + + Example usage: + + llm models options clear gpt-4o + # Or for a single option + llm models options clear gpt-4o temperature + +Options: + -h, --help Show this message and exit. +``` + +(help-templates)= +### llm templates --help +``` +Usage: llm templates [OPTIONS] COMMAND [ARGS]... + + Manage stored prompt templates + +Options: + -h, --help Show this message and exit. + +Commands: + list* List available prompt templates + edit Edit the specified prompt template using the default $EDITOR + loaders Show template loaders registered by plugins + path Output the path to the templates directory + show Show the specified prompt template +``` + +(help-templates-list)= +#### llm templates list --help +``` +Usage: llm templates list [OPTIONS] + + List available prompt templates + +Options: + -h, --help Show this message and exit. +``` + +(help-templates-show)= +#### llm templates show --help +``` +Usage: llm templates show [OPTIONS] NAME + + Show the specified prompt template + +Options: + -h, --help Show this message and exit. +``` + +(help-templates-edit)= +#### llm templates edit --help +``` +Usage: llm templates edit [OPTIONS] NAME + + Edit the specified prompt template using the default $EDITOR + +Options: + -h, --help Show this message and exit. +``` + +(help-templates-path)= +#### llm templates path --help +``` +Usage: llm templates path [OPTIONS] + + Output the path to the templates directory + +Options: + -h, --help Show this message and exit. +``` + +(help-templates-loaders)= +#### llm templates loaders --help +``` +Usage: llm templates loaders [OPTIONS] + + Show template loaders registered by plugins + +Options: + -h, --help Show this message and exit. +``` + +(help-schemas)= +### llm schemas --help +``` +Usage: llm schemas [OPTIONS] COMMAND [ARGS]... + + Manage stored schemas + +Options: + -h, --help Show this message and exit. + +Commands: + list* List stored schemas + dsl Convert LLM's schema DSL to a JSON schema + show Show a stored schema +``` + +(help-schemas-list)= +#### llm schemas list --help +``` +Usage: llm schemas list [OPTIONS] + + List stored schemas + +Options: + -d, --database FILE Path to log database + -q, --query TEXT Search for schemas matching this string + --full Output full schema contents + --json Output as JSON + --nl Output as newline-delimited JSON + -h, --help Show this message and exit. +``` + +(help-schemas-show)= +#### llm schemas show --help +``` +Usage: llm schemas show [OPTIONS] SCHEMA_ID + + Show a stored schema + +Options: + -d, --database FILE Path to log database + -h, --help Show this message and exit. +``` + +(help-schemas-dsl)= +#### llm schemas dsl --help +``` +Usage: llm schemas dsl [OPTIONS] INPUT + + Convert LLM's schema DSL to a JSON schema + + llm schema dsl 'name, age int, bio: their bio' + +Options: + --multi Wrap in an array + -h, --help Show this message and exit. +``` + +(help-tools)= +### llm tools --help +``` +Usage: llm tools [OPTIONS] COMMAND [ARGS]... + + Manage tools that can be made available to LLMs + +Options: + -h, --help Show this message and exit. + +Commands: + list* List available tools that have been provided by plugins +``` + +(help-tools-list)= +#### llm tools list --help +``` +Usage: llm tools list [OPTIONS] [TOOL_DEFS]... + + List available tools that have been provided by plugins + +Options: + --json Output as JSON + --functions TEXT Python code block or file path defining functions to + register as tools + -h, --help Show this message and exit. +``` + +(help-aliases)= +### llm aliases --help +``` +Usage: llm aliases [OPTIONS] COMMAND [ARGS]... + + Manage model aliases + +Options: + -h, --help Show this message and exit. + +Commands: + list* List current aliases + path Output the path to the aliases.json file + remove Remove an alias + set Set an alias for a model +``` + +(help-aliases-list)= +#### llm aliases list --help +``` +Usage: llm aliases list [OPTIONS] + + List current aliases + +Options: + --json Output as JSON + -h, --help Show this message and exit. +``` + +(help-aliases-set)= +#### llm aliases set --help +``` +Usage: llm aliases set [OPTIONS] ALIAS [MODEL_ID] + + Set an alias for a model + + Example usage: + + 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. + + llm aliases set mini -q 4o -q mini + +Options: + -q, --query TEXT Set alias for model matching these strings + -h, --help Show this message and exit. +``` + +(help-aliases-remove)= +#### llm aliases remove --help +``` +Usage: llm aliases remove [OPTIONS] ALIAS + + Remove an alias + + Example usage: + + $ llm aliases remove turbo + +Options: + -h, --help Show this message and exit. +``` + +(help-aliases-path)= +#### llm aliases path --help +``` +Usage: llm aliases path [OPTIONS] + + Output the path to the aliases.json file + +Options: + -h, --help Show this message and exit. +``` + +(help-fragments)= +### llm fragments --help +``` +Usage: llm fragments [OPTIONS] COMMAND [ARGS]... + + Manage fragments that are stored in the database + + Fragments are reusable snippets of text that are shared across multiple + prompts. + +Options: + -h, --help Show this message and exit. + +Commands: + list* List current fragments + loaders Show fragment loaders registered by plugins + remove Remove a fragment alias + set Set an alias for a fragment + show Display the fragment stored under an alias or hash +``` + +(help-fragments-list)= +#### llm fragments list --help +``` +Usage: llm fragments list [OPTIONS] + + List current fragments + +Options: + -q, --query TEXT Search for fragments matching these strings + --aliases Show only fragments with aliases + --json Output as JSON + -h, --help Show this message and exit. +``` + +(help-fragments-set)= +#### llm fragments set --help +``` +Usage: llm fragments set [OPTIONS] ALIAS FRAGMENT + + Set an alias for a fragment + + Accepts an alias and a file path, URL, hash or '-' for stdin + + Example usage: + + llm fragments set mydocs ./docs.md + +Options: + -h, --help Show this message and exit. +``` + +(help-fragments-show)= +#### llm fragments show --help +``` +Usage: llm fragments show [OPTIONS] ALIAS_OR_HASH + + Display the fragment stored under an alias or hash + + llm fragments show mydocs + +Options: + -h, --help Show this message and exit. +``` + +(help-fragments-remove)= +#### llm fragments remove --help +``` +Usage: llm fragments remove [OPTIONS] ALIAS + + Remove a fragment alias + + Example usage: + + llm fragments remove docs + +Options: + -h, --help Show this message and exit. +``` + +(help-fragments-loaders)= +#### llm fragments loaders --help +``` +Usage: llm fragments loaders [OPTIONS] + + Show fragment loaders registered by plugins + +Options: + -h, --help Show this message and exit. +``` + +(help-plugins)= +### llm plugins --help +``` +Usage: llm plugins [OPTIONS] + + List installed plugins + +Options: + --all Include built-in default plugins + --hook TEXT Filter for plugins that implement this hook + -h, --help Show this message and exit. +``` + +(help-install)= +### llm install --help +``` +Usage: llm install [OPTIONS] [PACKAGES]... + + Install packages from PyPI into the same environment as LLM + +Options: + -U, --upgrade Upgrade packages to latest version + -e, --editable TEXT Install a project in editable mode from this path + --force-reinstall Reinstall all packages even if they are already up-to- + date + --no-cache-dir Disable the cache + --pre Include pre-release and development versions + -h, --help Show this message and exit. +``` + +(help-uninstall)= +### llm uninstall --help +``` +Usage: llm uninstall [OPTIONS] PACKAGES... + + Uninstall Python packages from the LLM environment + +Options: + -y, --yes Don't ask for confirmation + -h, --help Show this message and exit. +``` + +(help-embed)= +### llm embed --help +``` +Usage: llm embed [OPTIONS] [COLLECTION] [ID] + + Embed text and store or return the result + +Options: + -i, --input PATH File to embed + -m, --model TEXT Embedding model to use + --store Store the text itself in the database + -d, --database FILE + -c, --content TEXT Content to embed + --binary Treat input as binary data + --metadata TEXT JSON object metadata to store + -f, --format [json|blob|base64|hex] + Output format + -h, --help Show this message and exit. +``` + +(help-embed-multi)= +### llm embed-multi --help +``` +Usage: llm embed-multi [OPTIONS] COLLECTION [INPUT_PATH] + + Store embeddings for multiple strings at once in the specified collection. + + Input data can come from one of three sources: + + 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 + + Examples: + llm embed-multi docs input.csv + cat data.json | llm embed-multi docs - + llm embed-multi docs input.json --format json + + 2. A SQL query against a SQLite database: + - First column returned is used as ID + - Other columns concatenated to form content + + 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" + + 3. Files in directories matching glob patterns: + - Each file becomes one embedding + - Relative file paths become IDs + + 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 + +Options: + --format [json|csv|tsv|nl] Format of input file - defaults to auto-detect + --files ... Embed files in this directory - specify directory + and glob pattern + --encoding TEXT Encodings to try when reading --files + --binary Treat --files as binary data + --sql TEXT Read input using this SQL query + --attach ... Additional databases to attach - specify alias + and file path + --batch-size INTEGER Batch size to use when running embeddings + --prefix TEXT Prefix to add to the IDs + -m, --model TEXT Embedding model to use + --prepend TEXT Prepend this string to all content before + embedding + --store Store the text itself in the database + -d, --database FILE + -h, --help Show this message and exit. +``` + +(help-similar)= +### llm similar --help +``` +Usage: llm similar [OPTIONS] COLLECTION [ID] + + Return top N similar IDs from a collection using cosine similarity. + + Example usage: + + llm similar my-collection -c "I like cats" + + Or to find content similar to a specific stored ID: + + llm similar my-collection 1234 + +Options: + -i, --input PATH File to embed for comparison + -c, --content TEXT Content to embed for comparison + --binary Treat input as binary data + -n, --number INTEGER Number of results to return + -p, --plain Output in plain text format + -d, --database FILE + --prefix TEXT Just IDs with this prefix + -h, --help Show this message and exit. +``` + +(help-embed-models)= +### llm embed-models --help +``` +Usage: llm embed-models [OPTIONS] COMMAND [ARGS]... + + Manage available embedding models + +Options: + -h, --help Show this message and exit. + +Commands: + list* List available embedding models + default Show or set the default embedding model +``` + +(help-embed-models-list)= +#### llm embed-models list --help +``` +Usage: llm embed-models list [OPTIONS] + + List available embedding models + +Options: + -q, --query TEXT Search for embedding models matching these strings + -h, --help Show this message and exit. +``` + +(help-embed-models-default)= +#### llm embed-models default --help +``` +Usage: llm embed-models default [OPTIONS] [MODEL] + + Show or set the default embedding model + +Options: + --remove-default Reset to specifying no default model + -h, --help Show this message and exit. +``` + +(help-collections)= +### llm collections --help +``` +Usage: llm collections [OPTIONS] COMMAND [ARGS]... + + View and manage collections of embeddings + +Options: + -h, --help Show this message and exit. + +Commands: + list* View a list of collections + delete Delete the specified collection + path Output the path to the embeddings database +``` + +(help-collections-path)= +#### llm collections path --help +``` +Usage: llm collections path [OPTIONS] + + Output the path to the embeddings database + +Options: + -h, --help Show this message and exit. +``` + +(help-collections-list)= +#### llm collections list --help +``` +Usage: llm collections list [OPTIONS] + + View a list of collections + +Options: + -d, --database FILE Path to embeddings database + --json Output as JSON + -h, --help Show this message and exit. +``` + +(help-collections-delete)= +#### llm collections delete --help +``` +Usage: llm collections delete [OPTIONS] COLLECTION + + Delete the specified collection + + Example usage: + + llm collections delete my-collection + +Options: + -d, --database FILE Path to embeddings database + -h, --help Show this message and exit. +``` + +(help-openai)= +### llm openai --help +``` +Usage: llm openai [OPTIONS] COMMAND [ARGS]... + + Commands for working directly with the OpenAI API + +Options: + -h, --help Show this message and exit. + +Commands: + models List models available to you from the OpenAI API +``` + +(help-openai-models)= +#### llm openai models --help +``` +Usage: llm openai models [OPTIONS] + + List models available to you from the OpenAI API + +Options: + --json Output as JSON + --key TEXT OpenAI API key + -h, --help Show this message and exit. +``` + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..4536f5d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,133 @@ +# LLM + +[![GitHub repo](https://img.shields.io/badge/github-repo-green)](https://github.com/simonw/llm) +[![PyPI](https://img.shields.io/pypi/v/llm.svg)](https://pypi.org/project/llm/) +[![Changelog](https://img.shields.io/github/v/release/simonw/llm?include_prereleases&label=changelog)](https://llm.datasette.io/en/stable/changelog.html) +[![Tests](https://github.com/simonw/llm/workflows/Test/badge.svg)](https://github.com/simonw/llm/actions?query=workflow%3ATest) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/llm/blob/main/LICENSE) +[![Discord](https://img.shields.io/discord/823971286308356157?label=discord)](https://datasette.io/discord-llm) +[![Homebrew](https://img.shields.io/homebrew/installs/dy/llm?color=yellow&label=homebrew&logo=homebrew)](https://formulae.brew.sh/formula/llm) + +A CLI tool and Python library for interacting with **OpenAI**, **Anthropic's Claude**, **Google's Gemini**, **Meta's Llama** and dozens of other Large Language Models, both via remote APIs and with models that can be installed and run on your own machine. + +Watch **[Language models on the command-line](https://www.youtube.com/watch?v=QUXQNi6jQ30)** on YouTube for a demo or [read the accompanying detailed notes](https://simonwillison.net/2024/Jun/17/cli-language-models/). + +With LLM you can: +- {ref}`Run prompts from the command-line ` +- {ref}`Store prompts and responses in SQLite ` +- {ref}`Generate and store embeddings ` +- {ref}`Extract structured content from text and images ` +- {ref}`Grant models the ability to execute tools ` +- ... and much, much more + +## Quick start + +First, install LLM using `pip` or Homebrew or `pipx` or `uv`: + +```bash +pip install llm +``` +Or with Homebrew (see {ref}`warning note `): +```bash +brew install llm +``` +Or with [pipx](https://pypa.github.io/pipx/): +```bash +pipx install llm +``` +Or with [uv](https://docs.astral.sh/uv/guides/tools/) +```bash +uv tool install llm +``` +If you have an [OpenAI API key](https://platform.openai.com/api-keys) key you can run this: +```bash +# Paste your OpenAI API key into this +llm keys set openai + +# Run a prompt (with the default gpt-4o-mini model) +llm "Ten fun names for a pet pelican" + +# Extract text from an image +llm "extract text" -a scanned-document.jpg + +# Use a system prompt against a file +cat myfile.py | llm -s "Explain this code" +``` +Run prompts against [Gemini](https://aistudio.google.com/apikey) or [Anthropic](https://console.anthropic.com/) with their respective plugins: +```bash +llm install llm-gemini +llm keys set gemini +# Paste Gemini API key here +llm -m gemini-2.0-flash 'Tell me fun facts about Mountain View' + +llm install llm-anthropic +llm keys set anthropic +# Paste Anthropic API key here +llm -m claude-4-opus 'Impress me with wild facts about turnips' +``` +You can also {ref}`install a plugin ` to access models that can run on your local device. If you use [Ollama](https://ollama.com/): +```bash +# Install the plugin +llm install llm-ollama + +# Download and run a prompt against the Orca Mini 7B model +ollama pull llama3.2:latest +llm -m llama3.2:latest 'What is the capital of France?' +``` +To start {ref}`an interactive chat ` with a model, use `llm chat`: +```bash +llm chat -m gpt-4.1 +``` +``` +Chatting with gpt-4.1 +Type 'exit' or 'quit' to exit +Type '!multi' to enter multiple lines, then '!end' to finish +Type '!edit' to open your default editor and modify the prompt. +Type '!fragment [ ...]' to insert one or more fragments +> Tell me a joke about a pelican +Why don't pelicans like to tip waiters? + +Because they always have a big bill! +``` + +More background on this project: + +- [llm, ttok and strip-tags—CLI tools for working with ChatGPT and other LLMs](https://simonwillison.net/2023/May/18/cli-tools-for-llms/) +- [The LLM CLI tool now supports self-hosted language models via plugins](https://simonwillison.net/2023/Jul/12/llm/) +- [LLM now provides tools for working with embeddings](https://simonwillison.net/2023/Sep/4/llm-embeddings/) +- [Build an image search engine with llm-clip, chat with models with llm chat](https://simonwillison.net/2023/Sep/12/llm-clip-and-chat/) +- [You can now run prompts against images, audio and video in your terminal using LLM](https://simonwillison.net/2024/Oct/29/llm-multi-modal/) +- [Structured data extraction from unstructured content using LLM schemas](https://simonwillison.net/2025/Feb/28/llm-schemas/) +- [Long context support in LLM 0.24 using fragments and template plugins](https://simonwillison.net/2025/Apr/7/long-context-llm/) + +See also [the llm tag](https://simonwillison.net/tags/llm/) on my blog. + +## Contents + +```{toctree} +--- +maxdepth: 3 +--- +setup +usage +openai-models +other-models +tools +schemas +templates +fragments +aliases +embeddings/index +plugins/index +python-api +logging +related-tools +help +contributing +``` +```{toctree} +--- +maxdepth: 1 +--- +changelog +``` diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..4fba39f --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,425 @@ +(logging)= +# Logging to SQLite + +`llm` defaults to logging all prompts and responses to a SQLite database. + +You can find the location of that database using the `llm logs path` command: + +```bash +llm logs path +``` +On my Mac that outputs: +``` +/Users/simon/Library/Application Support/io.datasette.llm/logs.db +``` +This will differ for other operating systems. + +To avoid logging an individual prompt, pass `--no-log` or `-n` to the command: +```bash +llm 'Ten names for cheesecakes' -n +``` + +To turn logging by default off: + +```bash +llm logs off +``` +If you've turned off logging you can still log an individual prompt and response by adding `--log`: +```bash +llm 'Five ambitious names for a pet pterodactyl' --log +``` +To turn logging by default back on again: + +```bash +llm logs on +``` +To see the status of the logs database, run this: +```bash +llm logs status +``` +Example output: +``` +Logging is ON for all prompts +Found log database at /Users/simon/Library/Application Support/io.datasette.llm/logs.db +Number of conversations logged: 33 +Number of responses logged: 48 +Database file size: 19.96MB +``` + +(logging-view)= + +## Viewing the logs + +You can view the logs using the `llm logs` command: +```bash +llm logs +``` +This will output the three most recent logged items in Markdown format, showing both the prompt and the response formatted using Markdown. + +To get back just the most recent prompt response as plain text, add `-r/--response`: + +```bash +llm logs -r +``` +Use `-x/--extract` to extract and return the first fenced code block from the selected log entries: + +```bash +llm logs --extract +``` +Or `--xl/--extract-last` for the last fenced code block: +```bash +llm logs --extract-last +``` + +Add `--json` to get the log messages in JSON instead: + +```bash +llm logs --json +``` + +Add `-n 10` to see the ten most recent items: +```bash +llm logs -n 10 +``` +Or `-n 0` to see everything that has ever been logged: +```bash +llm logs -n 0 +``` +You can truncate the display of the prompts and responses using the `-t/--truncate` option. This can help make the JSON output more readable - though the `--short` option is usually better. +```bash +llm logs -n 1 -t --json +``` +Example output: +```json +[ + { + "id": "01jm8ec74wxsdatyn5pq1fp0s5", + "model": "anthropic/claude-3-haiku-20240307", + "prompt": "hi", + "system": null, + "prompt_json": null, + "response": "Hello! How can I assist you today?", + "conversation_id": "01jm8ec74taftdgj2t4zra9z0j", + "duration_ms": 560, + "datetime_utc": "2025-02-16T22:34:30.374882+00:00", + "input_tokens": 8, + "output_tokens": 12, + "token_details": null, + "conversation_name": "hi", + "conversation_model": "anthropic/claude-3-haiku-20240307", + "attachments": [] + } +] +``` + +(logging-short)= + +### -s/--short mode + +Use `-s/--short` to see a shortened YAML log with truncated prompts and no responses: +```bash +llm logs -n 2 --short +``` +Example output: +```yaml +- model: deepseek-reasoner + datetime: '2025-02-02T06:39:53' + conversation: 01jk2pk05xq3d0vgk0202zrsg1 + prompt: H01 There are five huts. H02 The Scotsman lives in the purple hut. H03 The Welshman owns the parrot. H04 Kombucha is... +- model: o3-mini + datetime: '2025-02-02T19:03:05' + conversation: 01jk40qkxetedzpf1zd8k9bgww + system: Formatting re-enabled. Write a detailed README with extensive usage examples. + prompt: ./Cargo.toml [package] name = "py-limbo" version... +``` +Include `-u/--usage` to include token usage information: + +```bash +llm logs -n 1 --short --usage +``` +Example output: +```yaml +- model: o3-mini + datetime: '2025-02-16T23:00:56' + conversation: 01jm8fxxnef92n1663c6ays8xt + system: Produce Python code that demonstrates every possible usage of yaml.dump + with all of the arguments it can take, especi... + prompt: ./setup.py + NAME = 'PyYAML' VERSION = '7.0.0.dev0... + usage: + input: 74793 + output: 3550 + details: + completion_tokens_details: + reasoning_tokens: 2240 +``` + +(logging-conversation)= + +### Logs for a conversation + +To view the logs for the most recent {ref}`conversation ` you have had with a model, use `-c`: + +```bash +llm logs -c +``` +To see logs for a specific conversation based on its ID, use `--cid ID` or `--conversation ID`: + +```bash +llm logs --cid 01h82n0q9crqtnzmf13gkyxawg +``` + +(logging-search)= + +### Searching the logs + +You can search the logs for a search term in the `prompt` or the `response` columns. +```bash +llm logs -q 'cheesecake' +``` +The most relevant results will be shown first. + +To switch to sorting with most recent first, add `-l/--latest`. This can be combined with `-n` to limit the number of results shown: +```bash +llm logs -q 'cheesecake' -l -n 3 +``` + +(logging-filter-id)= + +### Filtering past a specific ID + +If you want to retrieve all of the logs that were recorded since a specific response ID you can do so using these options: + +- `--id-gt $ID` - every record with an ID greater than $ID +- `--id-gte $ID` - every record with an ID greater than or equal to $ID + +IDs are always issued in ascending order by time, so this provides a useful way to see everything that has happened since a particular record. + +This can be particularly useful when {ref}`working with schema data `, where you might want to access every record that you have created using a specific `--schema` but exclude records you have previously processed. + +(logging-filter-model)= + +### Filtering by model + +You can filter to logs just for a specific model (or model alias) using `-m/--model`: +```bash +llm logs -m chatgpt +``` + +(logging-filter-fragments)= + +### Filtering by prompts that used specific fragments + +The `-f/--fragment X` option will filter for just responses that were created using the specified {ref}`fragment ` hash or alias or URL or filename. + +Fragments are displayed in the logs as their hash ID. Add `-e/--expand` to display fragments as their full content - this option works for both the default Markdown and the `--json` mode: + +```bash +llm logs -f https://llm.datasette.io/robots.txt --expand +``` +You can display just the content for a specific fragment hash ID (or alias) using the `llm fragments show` command: + +```bash +llm fragments show 993fd38d898d2b59fd2d16c811da5bdac658faa34f0f4d411edde7c17ebb0680 +``` +If you provide multiple fragments you will get back responses that used _all_ of those fragments. + +(logging-filter-tools)= + +### Filtering by prompts that used specific tools + +You can filter for responses that used tools from specific fragments with the `--tool/-T` option: + +```bash +llm logs -T simple_eval +``` +This will match responses that involved a _result_ from that tool. If the tool was not executed it will not be included in the filtered responses. + +Pass `--tool/-T` multiple times for responses that used all of the specified tools. + +Use the `llm logs --tools` flag to see _all_ responses that involved at least one tool result, including from `--functions`: + +```bash +llm logs --tools +``` + +(logging-filter-schemas)= + +### Browsing data collected using schemas + +The `--schema X` option can be used to view responses that used the specified schema, using any of the {ref}`ways to specify a schema `: + +```bash +llm logs --schema 'name, age int, bio' +``` + +This can be combined with `--data` and `--data-array` and `--data-key` to extract just the returned JSON data - consult the {ref}`schemas documentation ` for details. + +(logging-datasette)= + +## Browsing logs using Datasette + +You can also use [Datasette](https://datasette.io/) to browse your logs like this: + +```bash +datasette "$(llm logs path)" +``` + +(logging-backup)= + +## Backing up your database + +You can backup your logs to another file using the `llm logs backup` command: + +```bash +llm logs backup /tmp/backup.db +``` +This uses SQLite [VACUUM INTO](https://sqlite.org/lang_vacuum.html#vacuum_with_an_into_clause) under the hood. + +(logging-sql-schema)= + +## SQL schema + +Here's the SQL schema used by the `logs.db` database: + + +```sql +CREATE TABLE "conversations" ( + "id" TEXT PRIMARY KEY, + "name" TEXT, + "model" TEXT +); +CREATE TABLE "schemas" ( + "id" TEXT PRIMARY KEY, + "content" TEXT +); +CREATE TABLE "responses" ( + "id" TEXT PRIMARY KEY, + "model" TEXT, + "prompt" TEXT, + "system" TEXT, + "prompt_json" TEXT, + "options_json" TEXT, + "response" TEXT, + "response_json" TEXT, + "conversation_id" TEXT REFERENCES "conversations"("id"), + "duration_ms" INTEGER, + "datetime_utc" TEXT, + "input_tokens" INTEGER, + "output_tokens" INTEGER, + "token_details" TEXT, + "schema_id" TEXT REFERENCES "schemas"("id"), + "resolved_model" TEXT, + "reasoning" TEXT +); +CREATE VIRTUAL TABLE "responses_fts" USING FTS5 ( + "prompt", + "response", + content="responses" +); +CREATE TABLE "attachments" ( + "id" TEXT PRIMARY KEY, + "type" TEXT, + "path" TEXT, + "url" TEXT, + "content" BLOB +); +CREATE TABLE "prompt_attachments" ( + "response_id" TEXT REFERENCES "responses"("id"), + "attachment_id" TEXT REFERENCES "attachments"("id"), + "order" INTEGER, + PRIMARY KEY ("response_id", + "attachment_id") +); +CREATE TABLE "fragments" ( + "id" INTEGER PRIMARY KEY, + "hash" TEXT, + "content" TEXT, + "datetime_utc" TEXT, + "source" TEXT +); +CREATE TABLE "fragment_aliases" ( + "alias" TEXT PRIMARY KEY, + "fragment_id" INTEGER REFERENCES "fragments"("id") +); +CREATE TABLE "prompt_fragments" ( + "response_id" TEXT REFERENCES "responses"("id"), + "fragment_id" INTEGER REFERENCES "fragments"("id"), + "order" INTEGER, + PRIMARY KEY ("response_id", + "fragment_id", + "order") +); +CREATE TABLE "system_fragments" ( + "response_id" TEXT REFERENCES "responses"("id"), + "fragment_id" INTEGER REFERENCES "fragments"("id"), + "order" INTEGER, + PRIMARY KEY ("response_id", + "fragment_id", + "order") +); +CREATE TABLE "tools" ( + "id" INTEGER PRIMARY KEY, + "hash" TEXT, + "name" TEXT, + "description" TEXT, + "input_schema" TEXT, + "plugin" TEXT +); +CREATE TABLE "tool_responses" ( + "tool_id" INTEGER REFERENCES "tools"("id"), + "response_id" TEXT REFERENCES "responses"("id"), + PRIMARY KEY ("tool_id", + "response_id") +); +CREATE TABLE "tool_calls" ( + "id" INTEGER PRIMARY KEY, + "response_id" TEXT REFERENCES "responses"("id"), + "tool_id" INTEGER REFERENCES "tools"("id"), + "name" TEXT, + "arguments" TEXT, + "tool_call_id" TEXT +); +CREATE TABLE "tool_results" ( + "id" INTEGER PRIMARY KEY, + "response_id" TEXT REFERENCES "responses"("id"), + "tool_id" INTEGER REFERENCES "tools"("id"), + "name" TEXT, + "output" TEXT, + "tool_call_id" TEXT, + "instance_id" INTEGER REFERENCES "tool_instances"("id"), + "exception" TEXT +); +CREATE TABLE "tool_instances" ( + "id" INTEGER PRIMARY KEY, + "plugin" TEXT, + "name" TEXT, + "arguments" TEXT +); +``` + +`responses_fts` configures [SQLite full-text search](https://www.sqlite.org/fts5.html) against the `prompt` and `response` columns in the `responses` table. diff --git a/docs/openai-models.md b/docs/openai-models.md new file mode 100644 index 0000000..7cf9d5b --- /dev/null +++ b/docs/openai-models.md @@ -0,0 +1,188 @@ +(openai-models)= + +# OpenAI models + +LLM ships with a default plugin for talking to OpenAI's API. OpenAI offer both language models and embedding models, and LLM can access both types. + +(openai-models-configuration)= + +## Configuration + +All OpenAI models are accessed using an API key. You can obtain one from [the API keys page](https://platform.openai.com/api-keys) on their site. + +Once you have created a key, configure LLM to use it by running: + +```bash +llm keys set openai +``` +Then paste in the API key. + +(openai-models-language)= + +## OpenAI language models + +Run `llm models` for a full list of available models. The OpenAI models supported by LLM are: + + +``` +OpenAI Chat: gpt-4o (aliases: 4o) +OpenAI Chat: chatgpt-4o-latest (aliases: chatgpt-4o) +OpenAI Chat: gpt-4o-mini (aliases: 4o-mini) +OpenAI Chat: gpt-4o-audio-preview +OpenAI Chat: gpt-4o-audio-preview-2024-12-17 +OpenAI Chat: gpt-4o-audio-preview-2024-10-01 +OpenAI Chat: gpt-4o-mini-audio-preview +OpenAI Chat: gpt-4o-mini-audio-preview-2024-12-17 +OpenAI Chat: gpt-4.1 (aliases: 4.1) +OpenAI Chat: gpt-4.1-mini (aliases: 4.1-mini) +OpenAI Chat: gpt-4.1-nano (aliases: 4.1-nano) +OpenAI Chat: gpt-3.5-turbo (aliases: 3.5, chatgpt) +OpenAI Chat: gpt-3.5-turbo-16k (aliases: chatgpt-16k, 3.5-16k) +OpenAI Chat: gpt-4 (aliases: 4, gpt4) +OpenAI Chat: gpt-4-32k (aliases: 4-32k) +OpenAI Chat: gpt-4-1106-preview +OpenAI Chat: gpt-4-0125-preview +OpenAI Chat: gpt-4-turbo-2024-04-09 +OpenAI Chat: gpt-4-turbo (aliases: gpt-4-turbo-preview, 4-turbo, 4t) +OpenAI Chat: gpt-4.5-preview-2025-02-27 +OpenAI Chat: gpt-4.5-preview (aliases: gpt-4.5) +OpenAI Responses: o1 +OpenAI Responses: o1-2024-12-17 +OpenAI Chat: o1-preview +OpenAI Chat: o1-mini +OpenAI Responses: o3-mini +OpenAI Responses: o3 +OpenAI Responses: o4-mini +OpenAI Responses: gpt-5 +OpenAI Responses: gpt-5-mini +OpenAI Responses: gpt-5-nano +OpenAI Responses: gpt-5-2025-08-07 +OpenAI Responses: gpt-5-mini-2025-08-07 +OpenAI Responses: gpt-5-nano-2025-08-07 +OpenAI Responses: gpt-5.1 +OpenAI Responses: gpt-5.1-chat-latest +OpenAI Responses: gpt-5.2 +OpenAI Responses: gpt-5.2-chat-latest +OpenAI Responses: gpt-5.4 +OpenAI Responses: gpt-5.4-2026-03-05 +OpenAI Responses: gpt-5.4-mini +OpenAI Responses: gpt-5.4-mini-2026-03-17 +OpenAI Responses: gpt-5.4-nano +OpenAI Responses: gpt-5.4-nano-2026-03-17 +OpenAI Responses: gpt-5.5 +OpenAI Responses: gpt-5.5-2026-04-23 +OpenAI Completion: gpt-3.5-turbo-instruct (aliases: 3.5-instruct, chatgpt-instruct) +``` + + +See [the OpenAI models documentation](https://platform.openai.com/docs/models) for details of each of these. + +`gpt-4o-mini` (aliased to `4o-mini`) is the least expensive model, and is the default for if you don't specify a model at all. Consult [OpenAI's model documentation](https://platform.openai.com/docs/models) for details of the other models. + +[o1-pro](https://platform.openai.com/docs/models/o1-pro) is not available through the Chat Completions API used by LLM's default OpenAI plugin. You can install the new [llm-openai-plugin](https://github.com/simonw/llm-openai-plugin) plugin to access that model. + +## Model features + +The following features work with OpenAI models: + +- {ref}`System prompts ` can be used to provide instructions that have a higher weight than the prompt itself. +- {ref}`Attachments `. Many OpenAI models support image inputs - check which ones using `llm models --options`. Any model that accepts images can also accept PDFs. +- {ref}`Schemas ` can be used to influence the JSON structure of the model output. +- {ref}`Model options ` can be used to set parameters like `temperature`. Use `llm models --options` for a full list of supported options. + +(openai-models-embedding)= + +## OpenAI embedding models + +Run `llm embed-models` for a list of {ref}`embedding models `. The following OpenAI embedding models are supported by LLM: + +``` +ada-002 (aliases: ada, oai) +3-small +3-large +3-small-512 +3-large-256 +3-large-1024 +``` + +The `3-small` model is currently the most inexpensive. `3-large` costs more but is more capable - see [New embedding models and API updates](https://openai.com/blog/new-embedding-models-and-api-updates) on the OpenAI blog for details and benchmarks. + +An important characteristic of any embedding model is the size of the vector it returns. Smaller vectors cost less to store and query, but may be less accurate. + +OpenAI `3-small` and `3-large` vectors can be safely truncated to lower dimensions without losing too much accuracy. The `-int` models provided by LLM are pre-configured to do this, so `3-large-256` is the `3-large` model truncated to 256 dimensions. + +The vector size of the supported OpenAI embedding models are as follows: + +| Model | Size | +| --- | --- | +| ada-002 | 1536 | +| 3-small | 1536 | +| 3-large | 3072 | +| 3-small-512 | 512 | +| 3-large-256 | 256 | +| 3-large-1024 | 1024 | + +(openai-completion-models)= + +## OpenAI completion models + +The `gpt-3.5-turbo-instruct` model is a little different - it is a completion model rather than a chat model, described in [the OpenAI completions documentation](https://platform.openai.com/docs/api-reference/completions/create). + +Completion models can be called with the `-o logprobs 3` option (not supported by chat models) which will cause LLM to store 3 log probabilities for each returned token in the SQLite database. Consult [this issue](https://github.com/simonw/llm/issues/284#issuecomment-1724772704) for details on how to read these values. + +(openai-extra-models)= + +## Adding more OpenAI models + +OpenAI occasionally release new models with new names. LLM aims to ship new releases to support these, but you can also configure them directly, by adding them to a `extra-openai-models.yaml` configuration file. + +Run this command to find the directory in which this file should be created: + +```bash +dirname "$(llm logs path)" +``` +On my Mac laptop I get this: +``` +~/Library/Application Support/io.datasette.llm +``` +Create a file in that directory called `extra-openai-models.yaml`. + +Let's say OpenAI have just released the `gpt-3.5-turbo-0613` model and you want to use it, despite LLM not yet shipping support. You could configure that by adding this to the file: + +```yaml +- model_id: gpt-3.5-turbo-0613 + model_name: gpt-3.5-turbo-0613 + aliases: ["0613"] +``` +The `model_id` is the identifier that will be recorded in the LLM logs. You can use this to specify the model, or you can optionally include a list of aliases for that model. The `model_name` is the actual model identifier that will be passed to the API, which must match exactly what the API expects. + +If the model is a completion model (such as `gpt-3.5-turbo-instruct`) add `completion: true` to the configuration. + +If the model supports structured extraction using json_schema, add `supports_schema: true` to the configuration. + +For reasoning models like `o1` or `o3-mini` add `reasoning: true`. + +With this configuration in place, the following command should run a prompt against the new model: + +```bash +llm -m 0613 'What is the capital of France?' +``` +Run `llm models` to confirm that the new model is now available: +```bash +llm models +``` +Example output: +``` +OpenAI Chat: gpt-3.5-turbo (aliases: 3.5, chatgpt) +OpenAI Chat: gpt-3.5-turbo-16k (aliases: chatgpt-16k, 3.5-16k) +OpenAI Chat: gpt-4 (aliases: 4, gpt4) +OpenAI Chat: gpt-4-32k (aliases: 4-32k) +OpenAI Chat: gpt-3.5-turbo-0613 (aliases: 0613) +``` +Running `llm logs -n 1` should confirm that the prompt and response has been correctly logged to the database. diff --git a/docs/other-models.md b/docs/other-models.md new file mode 100644 index 0000000..69ed415 --- /dev/null +++ b/docs/other-models.md @@ -0,0 +1,77 @@ +(other-models)= +# Other models + +LLM supports OpenAI models by default. You can install {ref}`plugins ` to add support for other models. You can also add additional OpenAI-API-compatible models {ref}`using a configuration file `. + +## Installing and using a local model + +{ref}`LLM plugins ` can provide local models that run on your machine. + +To install **[llm-gpt4all](https://github.com/simonw/llm-gpt4all)**, providing 17 models from the [GPT4All](https://gpt4all.io/) project, run this: + +```bash +llm install llm-gpt4all +``` +Run `llm models` to see the expanded list of available models. + +To run a prompt through one of the models from GPT4All specify it using `-m/--model`: +```bash +llm -m orca-mini-3b-gguf2-q4_0 'What is the capital of France?' +``` +The model will be downloaded and cached the first time you use it. + +Check the {ref}`plugin directory ` for the latest list of available plugins for other models. + +(openai-compatible-models)= + +## OpenAI-compatible models + +Projects such as [LocalAI](https://localai.io/) offer a REST API that imitates the OpenAI API but can be used to run other models, including models that can be installed on your own machine. These can be added using the same configuration mechanism. + +The `model_id` is the name LLM will use for the model. The `model_name` is the name which needs to be passed to the API - this might differ from the `model_id`, especially if the `model_id` could potentially clash with other installed models. + +The `api_base` key can be used to point the OpenAI client library at a different API endpoint. + +To add the `orca-mini-3b` model hosted by a local installation of [LocalAI](https://localai.io/), add this to your `extra-openai-models.yaml` file: + +```yaml +- model_id: orca-openai-compat + model_name: orca-mini-3b.ggmlv3 + api_base: "http://localhost:8080" +``` +If the `api_base` is set, the existing configured `openai` API key will not be sent by default. + +You can set `api_key_name` to the name of a key stored using the {ref}`api-keys` feature. + +Other keys you can use here: + +- `completion: true` for completion models that should use the `/completion` endpoint as opposed to `/completion/chat` +- `supports_tools: true` for models that support tool calling +- `can_stream: false` to disable streaming mode for models that cannot stream +- `supports_schema: true` for models that support JSON structured schema output +- `vision: true` for models that can accept images as input +- `audio: true` for models that accept audio attachments + +Having configured the model like this, run `llm models --options -m MODEL_ID` to check that it installed correctly. You can then run prompts against it like so: + +```bash +llm -m orca-openai-compat 'What is the capital of France?' +``` +And confirm they were logged correctly with: +```bash +llm logs -n 1 +``` + +### Extra HTTP headers + +Some providers such as [openrouter.ai](https://openrouter.ai/docs) may require the setting of additional HTTP headers. You can set those using the `headers:` key like this: + +```yaml +- model_id: claude + model_name: anthropic/claude-2 + api_base: "https://openrouter.ai/api/v1" + api_key_name: openrouter + headers: + HTTP-Referer: "https://llm.datasette.io/" + X-Title: LLM +``` diff --git a/docs/plugins/advanced-model-plugins.md b/docs/plugins/advanced-model-plugins.md new file mode 100644 index 0000000..3a1e2b9 --- /dev/null +++ b/docs/plugins/advanced-model-plugins.md @@ -0,0 +1,614 @@ +(advanced-model-plugins)= +# Advanced model plugins + +The {ref}`model plugin tutorial ` covers the basics of developing a plugin that adds support for a new model. This document covers more advanced topics. + +Features to consider for your model plugin include: + +- {ref}`Accepting API keys ` using the standard mechanism that incorporates `llm keys set`, environment variables and support for passing an explicit key to the model. +- Including support for {ref}`Async models ` that can be used with Python's `asyncio` library. +- Support for {ref}`structured output ` using JSON schemas. +- Support for {ref}`tools `. +- Handling {ref}`attachments ` (images, audio and more) for multi-modal models. +- Tracking {ref}`token usage ` for models that charge by the token. + +(advanced-model-plugins-lazy)= + +## Tip: lazily load expensive dependencies + +If your plugin depends on an expensive library such as [PyTorch](https://pytorch.org/) you should avoid importing that dependency (or a dependency that uses that dependency) at the top level of your module. Expensive imports in plugins mean that even simple commands like `llm --help` can take a long time to run. + +Instead, move those imports to inside the methods that need them. Here's an example [change to llm-sentence-transformers](https://github.com/simonw/llm-sentence-transformers/commit/f87df71e8a652a8cb05ad3836a79b815bcbfa64b) that shaved 1.8 seconds off the time it took to run `llm --help`! + +(advanced-model-plugins-api-keys)= + +## Models that accept API keys + +Models that call out to API providers such as OpenAI, Anthropic or Google Gemini usually require an API key. + +LLM's API key management mechanism {ref}`is described here `. + +If your plugin requires an API key you should subclass the `llm.KeyModel` class instead of the `llm.Model` class. Start your model definition like this: + +```python +import llm + +class HostedModel(llm.KeyModel): + needs_key = "hosted" # Required + key_env_var = "HOSTED_API_KEY" # Optional +``` +This tells LLM that your model requires an API key, which may be saved in the key registry under the key name `hosted` or might also be provided as the `HOSTED_API_KEY` environment variable. + +Then when you define your `execute()` method it should take an extra `key=` parameter like this: + +```python + def execute(self, prompt, stream, response, conversation, key=None): + # key= here will be the API key to use +``` +LLM will pass in the key from the environment variable, key registry or that has been passed to LLM as the `--key` command-line option or the `model.prompt(..., key=)` parameter. + +(advanced-model-plugins-async)= + +## Async models + +Plugins can optionally provide an asynchronous version of their model, suitable for use with Python [asyncio](https://docs.python.org/3/library/asyncio.html). This is particularly useful for remote models accessible by an HTTP API. + +The async version of a model subclasses `llm.AsyncModel` instead of `llm.Model`. It must implement an `async def execute()` async generator method instead of `def execute()`. + +This example shows a subset of the OpenAI default plugin illustrating how this method might work: + +```python +from typing import AsyncGenerator +import llm + +class MyAsyncModel(llm.AsyncModel): + # This can duplicate the model_id of the sync model: + model_id = "my-model-id" + + async def execute( + self, prompt, stream, response, conversation=None + ) -> AsyncGenerator[str, None]: + if stream: + completion = await client.chat.completions.create( + model=self.model_id, + messages=messages, + stream=True, + ) + async for chunk in completion: + yield chunk.choices[0].delta.content + else: + completion = await client.chat.completions.create( + model=self.model_name or self.model_id, + messages=messages, + stream=False, + ) + if completion.choices[0].message.content is not None: + yield completion.choices[0].message.content +``` +If your model takes an API key you should instead subclass `llm.AsyncKeyModel` and have a `key=` parameter on your `.execute()` method: + +```python +class MyAsyncModel(llm.AsyncKeyModel): + ... + async def execute( + self, prompt, stream, response, conversation=None, key=None + ) -> AsyncGenerator[str, None]: +``` + +This async model instance should then be passed to the `register()` method in the `register_models()` plugin hook: + +```python +@hookimpl +def register_models(register): + register( + MyModel(), MyAsyncModel(), aliases=("my-model-aliases",) + ) +``` + +The `prompt` object passed to your `execute()` method is an instance of {class}`~llm.Prompt`: + +```{eval-rst} +.. autoclass:: llm.Prompt + :members: prompt, system + :exclude-members: model, options +``` + +(advanced-model-plugins-schemas)= + +## Supporting schemas + +If your model supports {ref}`structured output ` against a defined JSON schema you can implement support by first adding `supports_schema = True` to the class: + +```python +class MyModel(llm.KeyModel): + ... + support_schema = True +``` +And then adding code to your `.execute()` method that checks for `prompt.schema` and, if it is present, uses that to prompt the model. + +`prompt.schema` will always be a Python dictionary representing a JSON schema, even if the user passed in a Pydantic model class. + +Check the [llm-gemini](https://github.com/simonw/llm-gemini) and [llm-anthropic](https://github.com/simonw/llm-anthropic) plugins for example of this pattern in action. + +(advanced-model-plugins-tools)= + +## Supporting tools + +Adding {ref}`tools support ` involves several steps: + +1. Add `supports_tools = True` to your model class. +2. If `prompt.tools` is populated, turn that list of `llm.Tool` objects into the correct format for your model. +3. Look out for requests to call tools in the responses from your model. Call `response.add_tool_call(llm.ToolCall(...))` for each of those. This should work for streaming and non-streaming and async and non-async cases. Pass the provider's tool call ID as `tool_call_id=` if there is one; if you omit it LLM synthesizes a unique `tc_`-prefixed id, since consumers rely on every tool call having one. +4. If your prompt has a `prompt.tool_results` list, pass the information from those `llm.ToolResult` objects to your model. +5. Include `prompt.tools` and `prompt.tool_results` and tool calls from `response.tool_calls_or_raise()` in the conversation history constructed by your plugin. +6. Make sure your code is OK with prompts that do not have `prompt.prompt` set to a value, since they may be carrying exclusively the results of a tool call. + +This [commit to llm-gemini](https://github.com/simonw/llm-gemini/commit/a7f1096cfbb733018eb41c29028a8cc6160be298) implementing tools helps demonstrate what this looks like for a real plugin. + +Here are the relevant dataclasses: + +```{eval-rst} +.. autoclass:: llm.Tool + +.. autoclass:: llm.ToolCall + +.. autoclass:: llm.ToolResult +``` + + +(advanced-model-plugins-attachments)= + +## Attachments for multi-modal models + +Models such as GPT-4o, Claude 3.5 Sonnet and Google's Gemini 1.5 are multi-modal: they accept input in the form of images and maybe even audio, video and other formats. + +LLM calls these **attachments**. Models can specify the types of attachments they accept and then implement special code in the `.execute()` method to handle them. + +See {ref}`the Python attachments documentation ` for details on using attachments in the Python API. + +### Specifying attachment types + +A `Model` subclass can list the types of attachments it accepts by defining a `attachment_types` class attribute: + +```python +class NewModel(llm.Model): + model_id = "new-model" + attachment_types = { + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + } +``` +These content types are detected when an attachment is passed to LLM using `llm -a filename`, or can be specified by the user using the `--attachment-type filename image/png` option. + +**Note:** MP3 files will have their attachment type detected as `audio/mpeg`, not `audio/mp3`. + +LLM will use the `attachment_types` attribute to validate that provided attachments should be accepted before passing them to the model. + +### Handling attachments + +The `prompt` object passed to the `execute()` method will have an `attachments` attribute containing a list of `Attachment` objects provided by the user. + +An `Attachment` instance has the following properties: + +- `url (str)`: The URL of the attachment, if it was provided as a URL +- `path (str)`: The resolved file path of the attachment, if it was provided as a file +- `type (str)`: The content type of the attachment, if it was provided +- `content (bytes)`: The binary content of the attachment, if it was provided + +Generally only one of `url`, `path` or `content` will be set. + +You should usually access the type and the content through one of these methods: + +- `attachment.resolve_type() -> str`: Returns the `type` if it is available, otherwise attempts to guess the type by looking at the first few bytes of content +- `attachment.content_bytes() -> bytes`: Returns the binary content, which it may need to read from a file or fetch from a URL +- `attachment.base64_content() -> str`: Returns that content as a base64-encoded string + +A `id()` method returns a database ID for this content, which is either a SHA256 hash of the binary content or, in the case of attachments hosted at an external URL, a hash of `{"url": url}` instead. This is an implementation detail which you should not need to access directly. + +Note that it's possible for a prompt with an attachments to not include a text prompt at all, in which case `prompt.prompt` will be `None`. + +Here's how the OpenAI plugin handles attachments, including the case where no `prompt.prompt` was provided: + +```python +if not prompt.attachments: + messages.append({"role": "user", "content": prompt.prompt}) +else: + attachment_message = [] + if prompt.prompt: + attachment_message.append({"type": "text", "text": prompt.prompt}) + for attachment in prompt.attachments: + attachment_message.append(_attachment(attachment)) + messages.append({"role": "user", "content": attachment_message}) + + +# And the code for creating the attachment message +def _attachment(attachment): + url = attachment.url + base64_content = "" + if not url or attachment.resolve_type().startswith("audio/"): + base64_content = attachment.base64_content() + url = f"data:{attachment.resolve_type()};base64,{base64_content}" + if attachment.resolve_type().startswith("image/"): + return {"type": "image_url", "image_url": {"url": url}} + else: + format_ = "wav" if attachment.resolve_type() == "audio/wav" else "mp3" + return { + "type": "input_audio", + "input_audio": { + "data": base64_content, + "format": format_, + }, + } +``` +As you can see, it uses `attachment.url` if that is available and otherwise falls back to using the `base64_content()` method to embed the image directly in the JSON sent to the API. For the OpenAI API audio attachments are always included as base64-encoded strings. + +### Attachments from previous conversations + +Conversation history — including attachments from prior turns — is available on the canonical `prompt.messages` list. See the [next section](#structured-messages-streaming) for how that works. + +(structured-messages-streaming)= + +## Structured messages and streaming events + +The 0.32 alpha introduced a richer contract for plugins than "yield strings": + +1. **`execute()` yields `StreamEvent` objects** (or plain `str`, still supported) so text, reasoning (thinking tokens), tool calls, and server-side tool results each surface as their own event type. The framework assembles these into typed `Part` objects. +2. **`build_messages` (or equivalent) reads `prompt.messages`** — a `list[llm.Message]` that is the complete input chain for this turn. +3. **Opaque provider tokens round-trip via `provider_metadata`** — Anthropic thinking signatures, Gemini thought signatures, OpenAI Responses API encrypted reasoning blobs. Plugins stash whatever the API returns, then echo it back on the next request. + +**Older plugins still work.** A plugin that still yields plain `str` from `execute()` works unchanged — each string is wrapped as a `StreamEvent(type="text", chunk=...)` internally. + +### Yielding StreamEvent from execute() + +```python +from llm.parts import StreamEvent + +def execute(self, prompt, stream, response, conversation, key=None): + messages = self.build_messages(prompt, conversation) + ... + + for chunk in provider_sdk.stream(...): + if chunk.type == "text": + yield StreamEvent(type="text", chunk=chunk.text) + elif chunk.type == "thinking": + yield StreamEvent(type="reasoning", chunk=chunk.text) +``` + +A `StreamEvent` has four frequently-used fields: + +- **`type`** — one of `"text"`, `"reasoning"`, `"tool_call_name"`, `"tool_call_args"`, `"tool_result"`. +- **`chunk`** — the text fragment. For tool calls this is the tool name (for `tool_call_name`) or a partial JSON string (for `tool_call_args`). +- **`tool_call_id`** — the provider's id for the tool call, set on `tool_call_name` / `tool_call_args` / `tool_result` events. Also the signal the framework uses to group tool-call events into one `ToolCallPart`. +- **`provider_metadata`** — an optional `dict[str, dict]` namespaced by provider name. Carries opaque data (signatures, encrypted blobs) that must be echoed back on future requests. + +Three additional fields exist for special cases: + +- **`server_executed: bool`** — set `True` for server-side tool calls (for example, Anthropic web search) and their results. This means the model ran the tool internally as part of responding to the prompt. +- **`tool_name`** — set on `tool_result` events to identify which tool this result came from. +- **`part_index: int | None`** — defaults to `None`, which means "let the framework decide which Part this event belongs to." Pass an explicit integer only when you need to override the default grouping (see [below](#part-index-overrides)). + +### How events group into Parts + +When you leave `part_index` as `None` (the default), the framework groups events using these rules: + +- **Consecutive same-family events concatenate.** Two `text` events in a row become one `TextPart`. Two `reasoning` events in a row become one `ReasoningPart`. A family transition (text → reasoning, or reasoning → text) starts a new Part. +- **Tool calls group by `tool_call_id`.** A `tool_call_name` and any number of `tool_call_args` events sharing a `tool_call_id` combine into one `ToolCallPart` — even if they're interleaved with other events (parallel tool calls). +- **`tool_result` is always its own Part**, paired to the originating call by `tool_call_id`. + +| Stream | Resulting Parts | +|-------------------------------------------|----------------------------------------------------------| +| `text` × N | one `TextPart` | +| `reasoning` × N, then `text` × N | `ReasoningPart`, `TextPart` | +| `text`, `tool_call_name`+`args`, `text` | `TextPart`, `ToolCallPart`, `TextPart` | +| Parallel tool calls (interleaved by id) | one `ToolCallPart` per distinct `tool_call_id` | +| `reasoning`, tool call, `reasoning` | `ReasoningPart`, `ToolCallPart`, `ReasoningPart` | + +(part-index-overrides)= +### Setting `part_index` explicitly + +In rare cases you'll want to override the default grouping: + +- **Forcing a single TextPart across non-adjacent text bursts.** If your provider interleaves text deltas with tool calls but you want all the text concatenated into one `TextPart`, pass `part_index=0` on every text event. (The default behavior produces separate `TextPart`s on each side of the tool calls — usually what you want, but not always.) +- **Tool-call args arriving before the id.** If your provider streams args before the `tool_call_id` is known, assign your own index per logical tool call and pass it on each event of that call. + +You can mix explicit indices with `None` in the same stream — the framework reserves your explicit values and decides the rest. + +(advanced-model-plugins-reasoning-tokens)= +### Reasoning tokens + +For streamed reasoning text: + +```python +yield StreamEvent(type="reasoning", chunk=text_chunk) +``` + +Reasoning events that appear before/after text events become distinct `ReasoningPart` and `TextPart` entries in `response.messages` automatically. If your provider emits two thinking blocks separated by a tool call, you'll get two `ReasoningPart`s. + +Plugins should respect `prompt.hide_reasoning`. This is set when the caller passes `hide_reasoning=True` to `model.prompt()`, `conversation.prompt()`, `model.chain()`, `conversation.chain()`, or their async counterparts. It is also set by the CLI `-R/--hide-reasoning` option. + +`prompt.hide_reasoning` means "hide visible reasoning output", not "disable model reasoning". If your provider requires an explicit request for visible reasoning summaries, do not request those summaries when `prompt.hide_reasoning` is true: + +```python +kwargs = {} +if not prompt.hide_reasoning: + kwargs["reasoning"] = {"summary": "auto"} +``` + +If your provider emits reasoning blocks regardless of request parameters, keep yielding those reasoning events as usual: + +```python +if chunk.type == "thinking": + yield StreamEvent(type="reasoning", chunk=chunk.text) +``` + +LLM's display layers use `prompt.hide_reasoning` to avoid showing those events to the user, while still allowing the framework to persist `ReasoningPart` objects and provider metadata for logs, serialization, and future turns. + +### Tool calls + +Each tool call emits two event types sharing a `tool_call_id`: + +```python +yield StreamEvent( + type="tool_call_name", + chunk=tool_name, + tool_call_id=tool_call_id, +) +# then, as the provider streams JSON args: +yield StreamEvent( + type="tool_call_args", + chunk=partial_json_fragment, + tool_call_id=tool_call_id, +) +``` + +The framework groups them by `tool_call_id` — so parallel tool calls (where args for tool A and tool B interleave on the wire) work without any per-call index tracking. Some providers (Gemini) emit the complete tool call in one chunk — it's OK to emit both events back-to-back with the full name and full JSON. + +For client-side tool calls — tools that LLM should execute locally in a chain — **also call `response.add_tool_call()`**. The chain-execution path (`response.tool_calls()` → `execute_tool_calls()`) reads from the explicitly-added list, not from the StreamEvent buffer. + +```python +response.add_tool_call( + llm.ToolCall( + tool_call_id=tool_id, + name=tool_name, + arguments=parsed_args, + ) +) +``` + +### Server-side tool calls + +For tools the API executes internally, set `server_executed=True` on the events. Anthropic web search is an example: the API returns a `server_tool_use` block for the search request, followed by a `web_search_tool_result` block containing the result payload. + +```python +yield StreamEvent( + type="tool_call_name", + chunk="web_search", + tool_call_id=tool_id, + server_executed=True, +) +yield StreamEvent( + type="tool_call_args", + chunk=json.dumps(query_args), + tool_call_id=tool_id, + server_executed=True, +) +``` + +The tool *result* (for example, the search hits) is also emitted as an event: + +```python +yield StreamEvent( + type="tool_result", + chunk=human_readable_summary, + tool_call_id=tool_id, + server_executed=True, + tool_name="web_search", + provider_metadata={"myprovider": {"raw_content": full_payload}}, +) +``` + +For providers that don't stream server-tool-result contents (Anthropic's `web_search_tool_result` blocks only arrive in the final message), emit those results as a post-stream step. After the main iteration loop completes, inspect the final message and emit tool_result events for any server-side results. + +Do **not** call `response.add_tool_call()` for server-side tool calls. This method should only be used for tool calls that need to be executed locally by the framework. + +### Opaque provider metadata + +Some providers require you to echo back opaque fields on the next request for multi-turn continuity to work: + +- **Anthropic** — `signature` on each thinking block; `encrypted_content` inside web_search_tool_result items. +- **Google Gemini** — `thoughtSignature` on `functionCall` parts when thinking is active. +- **OpenAI Responses API** — `encrypted_content` on reasoning items in stateless mode. + +These values are attached to a `StreamEvent` via its `provider_metadata` field. The framework merges metadata across events that group into the same Part (last non-None wins per top-level key) and persists it on the finalized Part. + +Namespace under your provider's name so transcripts that mix providers don't collide: + +```python +# Anthropic signature arrives at the end of a thinking block. +yield StreamEvent( + type="reasoning", + chunk="", + provider_metadata={"anthropic": {"signature": sig}}, +) +``` + +```python +# Gemini attaches thoughtSignature to a functionCall part. +yield StreamEvent( + type="tool_call_name", + chunk=name, + tool_call_id=tc_id, + provider_metadata={"gemini": {"thoughtSignature": sig}}, +) +``` + +The framework round-trips the value verbatim via JSON, so use JSON-safe primitives (string, int, bool, dict, list) for provider metadata - use base64 encoding if you need to store binary data. + +### Non-streaming path + +When `stream=False` (or the provider returns a complete message at once), emit one event per content block. + +```python +else: + completion = client.messages.create(**kwargs) + response.response_json = completion.model_dump() + for block in completion.content: + if block.type == "thinking": + yield StreamEvent( + type="reasoning", + chunk=block.thinking, + provider_metadata={"anthropic": {"signature": block.signature}}, + ) + elif block.type == "text": + yield StreamEvent(type="text", chunk=block.text) + elif block.type == "tool_use": + yield StreamEvent( + type="tool_call_name", + chunk=block.name, + tool_call_id=block.id, + ) + yield StreamEvent( + type="tool_call_args", + chunk=json.dumps(block.input), + tool_call_id=block.id, + ) +``` + +## Consuming prompt.messages in build_messages + +`prompt.messages` is an `list[llm.Message]` that is always **the complete input chain for this turn** — whether the caller supplied it explicitly via `model.prompt(messages=[...])`, or it was synthesized from kwargs (`prompt=`, `system=`, `attachments=`, `tool_results=`), or it was pre-built by a `Conversation` or by `response.reply()`. + +**Do not also walk `conversation.responses`.** History is already baked into `prompt.messages`; walking the conversation would double-emit. + +A plugin's `build_messages` (or equivalent) iterates `prompt.messages` and dispatches per `Part` subtype: + +```python +from llm.parts import ( + TextPart, + ReasoningPart, + ToolCallPart, + ToolResultPart, + AttachmentPart, +) + +def build_messages(self, prompt, conversation): + messages = [] + for msg in prompt.messages: + if msg.role == "system": + # Some APIs put system on a separate kwarg (Anthropic, Gemini). + # OpenAI-style APIs emit it as a message; handle accordingly. + continue + self._append_message(messages, msg) + return messages + +def _append_message(self, out, msg): + # Map llm's role to the provider's (assistant→model for Gemini, + # tool→user for Anthropic/Gemini tool_result convention, etc.) + role = self._provider_role(msg.role) + parts = [] + for part in msg.parts: + if isinstance(part, TextPart): + parts.append({"type": "text", "text": part.text}) + elif isinstance(part, ReasoningPart): + # Skip redacted reasoning (no content to echo back). + if part.redacted or not part.text: + continue + block = {"type": "thinking", "thinking": part.text} + # Restore the signature from provider_metadata. + sig = (part.provider_metadata or {}).get("anthropic", {}).get("signature") + if sig: + block["signature"] = sig + parts.append(block) + elif isinstance(part, ToolCallPart): + parts.append({ + "type": "tool_use", + "id": part.tool_call_id, + "name": part.name, + "input": part.arguments, + }) + elif isinstance(part, ToolResultPart): + parts.append({ + "type": "tool_result", + "tool_use_id": part.tool_call_id, + "content": part.output, + }) + elif isinstance(part, AttachmentPart) and part.attachment: + parts.append(self._attachment_block(part.attachment)) + # Merge with the previous message if roles match (some providers + # require strict alternation between user and assistant). + if out and out[-1]["role"] == role: + out[-1]["content"].extend(parts) + else: + out.append({"role": role, "content": parts}) +``` + +## Restoring opaque metadata on subsequent requests + +When a conversation continues, your `build_messages` walks prior-turn Parts via `prompt.messages`. Each Part's `provider_metadata` is a `dict[str, dict]` keyed by provider name — extract your namespace and fold the fields back into the outgoing request body: + +```python +if isinstance(part, ReasoningPart): + block = {"type": "thinking", "thinking": part.text} + pm = (part.provider_metadata or {}).get("anthropic", {}) + if "signature" in pm: + block["signature"] = pm["signature"] + parts.append(block) + +if isinstance(part, ToolCallPart): + fc_part = {"function_call": {"name": part.name, "args": part.arguments}} + pm = (part.provider_metadata or {}).get("gemini", {}) + if "thoughtSignature" in pm: + # Gemini expects thoughtSignature beside function_call, + # not nested inside it. + fc_part["thoughtSignature"] = pm["thoughtSignature"] + parts.append(fc_part) +``` + +If the key is missing (an older transcript that pre-dates your plugin's support), fall through — don't fail. Treat other providers' entries as opaque; don't parse them. + +(advanced-model-plugins-usage)= + +## Tracking token usage + +Models that charge by the token should track the number of tokens used by each prompt. The ``response.set_usage()`` method can be used to record the number of tokens used by a response - these will then be made available through the Python API and logged to the SQLite database for command-line users. + +`response` here is the response object that is passed to `.execute()` as an argument. + +Call ``response.set_usage()`` at the end of your `.execute()` method. It accepts keyword arguments `input=`, `output=` and `details=` - all three are optional. `input` and `output` should be integers, and `details` should be a dictionary that provides additional information beyond the input and output token counts. + +This example logs 15 input tokens, 340 output tokens and notes that 37 tokens were cached: + +```python +response.set_usage(input=15, output=340, details={"cached": 37}) +``` +(advanced-model-plugins-resolved-model)= + +## Tracking resolved model names + +In some cases the model ID that the user requested may not be the exact model that is executed. Many providers have a `model-latest` alias which may execute different models over time. + +If those APIs return the _real_ model ID that was used, your plugin can record that in the `resources.resolved_model` column in the logs by calling this method and passing the string representing the resolved, final model ID: + +```bash +response.set_resolved_model(resolved_model_id) +``` +This string will be recorded in the database and shown in the output of `llm logs` and `llm logs --json`. + +(tutorial-model-plugin-raise-errors)= + +## LLM_RAISE_ERRORS + +While working on a plugin it can be useful to request that errors are raised instead of being caught and logged, so you can access them from the Python debugger. + +Set the `LLM_RAISE_ERRORS` environment variable to enable this behavior, then run `llm` like this: + +```bash +LLM_RAISE_ERRORS=1 python -i -m llm ... +``` +The `-i` option means Python will drop into an interactive shell if an error occurs. You can then open a debugger at the most recent error using: + +```python +import pdb; pdb.pm() +``` diff --git a/docs/plugins/directory.md b/docs/plugins/directory.md new file mode 100644 index 0000000..f261939 --- /dev/null +++ b/docs/plugins/directory.md @@ -0,0 +1,96 @@ +(plugin-directory)= +# Plugin directory + +The following plugins are available for LLM. Here's {ref}`how to install them `. + +(plugin-directory-local-models)= +## Local models + +These plugins all help you run LLMs directly on your own computer: + +- **[llm-gguf](https://github.com/simonw/llm-gguf)** uses [llama.cpp](https://github.com/ggerganov/llama.cpp) to run models published in the GGUF format. +- **[llm-mlx](https://github.com/simonw/llm-mlx)** (Mac only) uses Apple's MLX framework to provide extremely high performance access to a large number of local models. +- **[llm-ollama](https://github.com/taketwo/llm-ollama)** adds support for local models run using [Ollama](https://ollama.ai/). +- **[llm-llamafile](https://github.com/simonw/llm-llamafile)** adds support for local models that are running locally using [llamafile](https://github.com/Mozilla-Ocho/llamafile). +- **[llm-mlc](https://github.com/simonw/llm-mlc)** can run local models released by the [MLC project](https://mlc.ai/mlc-llm/), including models that can take advantage of the GPU on Apple Silicon M1/M2 devices. +- **[llm-gpt4all](https://github.com/simonw/llm-gpt4all)** adds support for various models released by the [GPT4All](https://gpt4all.io/) project that are optimized to run locally on your own machine. These models include versions of Vicuna, Orca, Falcon and MPT - here's [a full list of models](https://observablehq.com/@simonw/gpt4all-models). +- **[llm-mpt30b](https://github.com/simonw/llm-mpt30b)** adds support for the [MPT-30B](https://huggingface.co/mosaicml/mpt-30b) local model. + +(plugin-directory-remote-apis)= +## Remote APIs + +These plugins can be used to interact with remotely hosted models via their API: + +- **[llm-mistral](https://github.com/simonw/llm-mistral)** adds support for [Mistral AI](https://mistral.ai/)'s language and embedding models. +- **[llm-gemini](https://github.com/simonw/llm-gemini)** adds support for Google's [Gemini](https://ai.google.dev/docs) models. +- **[llm-anthropic](https://github.com/simonw/llm-anthropic)** supports Anthropic's [Claude 3 family](https://www.anthropic.com/news/claude-3-family), [3.5 Sonnet](https://www.anthropic.com/news/claude-3-5-sonnet) and beyond. +- **[llm-command-r](https://github.com/simonw/llm-command-r)** supports Cohere's Command R and [Command R Plus](https://txt.cohere.com/command-r-plus-microsoft-azure/) API models. +- **[llm-reka](https://github.com/simonw/llm-reka)** supports the [Reka](https://www.reka.ai/) family of models via their API. +- **[llm-perplexity](https://github.com/hex/llm-perplexity)** by Alexandru Geana supports the [Perplexity Labs](https://docs.perplexity.ai/) API models, including `llama-3-sonar-large-32k-online` which can search for things online and `llama-3-70b-instruct`. +- **[llm-groq](https://github.com/angerman/llm-groq)** by Moritz Angermann provides access to fast models hosted by [Groq](https://console.groq.com/docs/models). +- **[llm-grok](https://github.com/Hiepler/llm-grok)** by Benedikt Hiepler providing access to Grok model using the xAI API [Grok](https://x.ai/api). +- **[llm-anyscale-endpoints](https://github.com/simonw/llm-anyscale-endpoints)** supports models hosted on the [Anyscale Endpoints](https://app.endpoints.anyscale.com/) platform, including Llama 2 70B. +- **[llm-replicate](https://github.com/simonw/llm-replicate)** adds support for remote models hosted on [Replicate](https://replicate.com/), including Llama 2 from Meta AI. +- **[llm-fireworks](https://github.com/simonw/llm-fireworks)** supports models hosted by [Fireworks AI](https://fireworks.ai/). +- **[llm-openrouter](https://github.com/simonw/llm-openrouter)** provides access to models hosted on [OpenRouter](https://openrouter.ai/). +- **[llm-cohere](https://github.com/Accudio/llm-cohere)** by Alistair Shepherd provides `cohere-generate` and `cohere-summarize` API models, powered by [Cohere](https://cohere.com/). +- **[llm-bedrock](https://github.com/simonw/llm-bedrock)** adds support for Nova by Amazon via Amazon Bedrock. +- **[llm-bedrock-anthropic](https://github.com/sblakey/llm-bedrock-anthropic)** by Sean Blakey adds support for Claude and Claude Instant by Anthropic via Amazon Bedrock. +- **[llm-bedrock-meta](https://github.com/flabat/llm-bedrock-meta)** by Fabian Labat adds support for Llama 2 and Llama 3 by Meta via Amazon Bedrock. +- **[llm-together](https://github.com/wearedevx/llm-together)** adds support for the [Together AI](https://www.together.ai/) extensive family of hosted openly licensed models. +- **[llm-deepseek](https://github.com/abrasumente233/llm-deepseek)** adds support for the [DeepSeek](https://deepseek.com)'s DeepSeek-Chat and DeepSeek-Coder models. +- **[llm-lambda-labs](https://github.com/simonw/llm-lambda-labs)** provides access to models hosted by [Lambda Labs](https://docs.lambdalabs.com/public-cloud/lambda-chat-api/), including the Nous Hermes 3 series. +- **[llm-venice](https://github.com/ar-jan/llm-venice)** provides access to uncensored models hosted by privacy-focused [Venice AI](https://docs.venice.ai/), including Llama 3.1 405B. + +If an API model host provides an OpenAI-compatible API you can also [configure LLM to talk to it](https://llm.datasette.io/en/stable/other-models.html#openai-compatible-models) without needing an extra plugin. + +(plugin-directory-tools)= +## Tools + +The following plugins add new {ref}`tools ` that can be used by models: + +- **[llm-tools-simpleeval](https://github.com/simonw/llm-tools-simpleeval)** implements simple expression support for things like mathematics. +- **[llm-tools-quickjs](https://github.com/simonw/llm-tools-quickjs)** provides access to a sandboxed QuickJS JavaScript interpreter, allowing LLMs to run JavaScript code. The environment persists between calls so the model can set variables and build functions and reuse them later on. +- **[llm-tools-sqlite](https://github.com/simonw/llm-tools-sqlite)** can run read-only SQL queries against local SQLite databases. +- **[llm-tools-datasette](https://github.com/simonw/llm-tools-datasette)** can run SQL queries against a remote [Datasette](https://datasette.io/) instance. +- **[llm-tools-exa](https://github.com/daturkel/llm-tools-exa)** by Dan Turkel can perform web searches and question-answering using [exa.ai](https://exa.ai/). +- **[llm-tools-rag](https://github.com/daturkel/llm-tools-rag)** by Dan Turkel can perform searches over your LLM embedding collections for simple RAG. + +(plugin-directory-loaders)= +## Fragments and template loaders + +{ref}`LLM 0.24 ` introduced support for plugins that define `-f prefix:value` or `-t prefix:value` custom loaders for fragments and templates. + +- **[llm-video-frames](https://github.com/simonw/llm-video-frames)** uses `ffmpeg` to turn a video into a sequence of JPEG frames suitable for feeding into a vision model that doesn't support video inputs: `llm -f video-frames:video.mp4 'describe the key scenes in this video'`. +- **[llm-templates-github](https://github.com/simonw/llm-templates-github)** supports loading templates shared on GitHub, e.g. `llm -t gh:simonw/pelican-svg`. +- **[llm-templates-fabric](https://github.com/simonw/llm-templates-fabric)** provides access to the [Fabric](https://github.com/danielmiessler/fabric) collection of prompts: `cat setup.py | llm -t fabric:explain_code`. +- **[llm-fragments-github](https://github.com/simonw/llm-fragments-github)** can load entire GitHub repositories in a single operation: `llm -f github:simonw/files-to-prompt 'explain this code'`. It can also fetch issue threads as Markdown using `llm -f issue:https://github.com/simonw/llm-fragments-github/issues/3`. +- **[llm-hacker-news](https://github.com/simonw/llm-hacker-news)** imports conversations from Hacker News as fragments: `llm -f hn:43615912 'summary with illustrative direct quotes'`. +- **[llm-fragments-pypi](https://github.com/samueldg/llm-fragments-pypi)** loads [PyPI](https://pypi.org/) packages' description and metadata as fragments: `llm -f pypi:ruff "What flake8 plugins does ruff re-implement?"`. +- **[llm-fragments-pdf](https://github.com/daturkel/llm-fragments-pdf)** by Dan Turkel converts PDFs to markdown with [PyMuPDF4LLM](https://pymupdf.readthedocs.io/en/latest/pymupdf4llm/index.html) to use as fragments: `llm -f pdf:something.pdf "what's this about?"`. +- **[llm-fragments-site-text](https://github.com/daturkel/llm-fragments-site-text)** by Dan Turkel converts websites to markdown with [Trafilatura](https://trafilatura.readthedocs.io/en/latest/) to use as fragments: `llm -f site:https://example.com "summarize this"`. +- **[llm-fragments-reader](https://github.com/simonw/llm-fragments-reader)** runs a URL theough the Jina Reader API: `llm -f 'reader:https://simonwillison.net/tags/jina/' summary`. + +(plugin-directory-embeddings)= +## Embedding models + +{ref}`Embedding models ` are models that can be used to generate and store embedding vectors for text. + +- **[llm-sentence-transformers](https://github.com/simonw/llm-sentence-transformers)** adds support for embeddings using the [sentence-transformers](https://www.sbert.net/) library, which provides access to [a wide range](https://www.sbert.net/docs/pretrained_models.html) of embedding models. +- **[llm-clip](https://github.com/simonw/llm-clip)** provides the [CLIP](https://openai.com/research/clip) model, which can be used to embed images and text in the same vector space, enabling text search against images. See [Build an image search engine with llm-clip](https://simonwillison.net/2023/Sep/12/llm-clip-and-chat/) for more on this plugin. +- **[llm-embed-jina](https://github.com/simonw/llm-embed-jina)** provides Jina AI's [8K text embedding models](https://jina.ai/news/jina-ai-launches-worlds-first-open-source-8k-text-embedding-rivaling-openai/). +- **[llm-embed-onnx](https://github.com/simonw/llm-embed-onnx)** provides seven embedding models that can be executed using the ONNX model framework. + +(plugin-directory-commands)= +## Extra commands + +- **[llm-cmd](https://github.com/simonw/llm-cmd)** accepts a prompt for a shell command, runs that prompt and populates the result in your shell so you can review it, edit it and then hit `` to execute or `ctrl+c` to cancel. +- **[llm-cmd-comp](https://github.com/CGamesPlay/llm-cmd-comp)** provides a key binding for your shell that will launch a chat to build the command. When ready, hit `` and it will go right back into your shell command line, so you can run it. +- **[llm-python](https://github.com/simonw/llm-python)** adds a `llm python` command for running a Python interpreter in the same virtual environment as LLM. This is useful for debugging, and also provides a convenient way to interact with the LLM {ref}`python-api` if you installed LLM using Homebrew or `pipx`. +- **[llm-cluster](https://github.com/simonw/llm-cluster)** adds a `llm cluster` command for calculating clusters for a collection of embeddings. Calculated clusters can then be passed to a Large Language Model to generate a summary description. +- **[llm-jq](https://github.com/simonw/llm-jq)** lets you pipe in JSON data and a prompt describing a `jq` program, then executes the generated program against the JSON. + +(plugin-directory-fun)= +## Just for fun + +- **[llm-markov](https://github.com/simonw/llm-markov)** adds a simple model that generates output using a [Markov chain](https://en.wikipedia.org/wiki/Markov_chain). This example is used in the tutorial [Writing a plugin to support a new model](https://llm.datasette.io/en/latest/plugins/tutorial-model-plugin.html). diff --git a/docs/plugins/index.md b/docs/plugins/index.md new file mode 100644 index 0000000..2a08844 --- /dev/null +++ b/docs/plugins/index.md @@ -0,0 +1,22 @@ +(plugins)= +# Plugins + +LLM plugins can enhance LLM by making alternative Large Language Models available, either via API or by running the models locally on your machine. + +Plugins can also add new commands to the `llm` CLI tool. + +The {ref}`plugin directory ` lists available plugins that you can install and use. + +{ref}`tutorial-model-plugin` describes how to build a new plugin in detail. + +```{toctree} +--- +maxdepth: 3 +--- +installing-plugins +directory +plugin-hooks +tutorial-model-plugin +advanced-model-plugins +plugin-utilities +``` diff --git a/docs/plugins/installing-plugins.md b/docs/plugins/installing-plugins.md new file mode 100644 index 0000000..e4d1055 --- /dev/null +++ b/docs/plugins/installing-plugins.md @@ -0,0 +1,101 @@ +(installing-plugins)= +# Installing plugins + +Plugins must be installed in the same virtual environment as LLM itself. + +You can find names of plugins to install in the {ref}`plugin directory ` + +Use the `llm install` command (a thin wrapper around `pip install`) to install plugins in the correct environment: +```bash +llm install llm-gpt4all +``` +Plugins can be uninstalled with `llm uninstall`: +```bash +llm uninstall llm-gpt4all -y +``` +The `-y` flag skips asking for confirmation. + +You can see additional models that have been added by plugins by running: +```bash +llm models +``` +Or add `--options` to include details of the options available for each model: +```bash +llm models --options +``` +To run a prompt against a newly installed model, pass its name as the `-m/--model` option: +```bash +llm -m orca-mini-3b-gguf2-q4_0 'What is the capital of France?' +``` + +## Listing installed plugins + +Run `llm plugins` to list installed plugins: + +```bash +llm plugins +``` +```json +[ + { + "name": "llm-anthropic", + "hooks": [ + "register_models" + ], + "version": "0.11" + }, + { + "name": "llm-gguf", + "hooks": [ + "register_commands", + "register_models" + ], + "version": "0.1a0" + }, + { + "name": "llm-clip", + "hooks": [ + "register_commands", + "register_embedding_models" + ], + "version": "0.1" + }, + { + "name": "llm-cmd", + "hooks": [ + "register_commands" + ], + "version": "0.2a0" + }, + { + "name": "llm-gemini", + "hooks": [ + "register_embedding_models", + "register_models" + ], + "version": "0.3" + } +] +``` + +(llm-load-plugins)= +## Running with a subset of plugins + +By default, LLM will load all plugins that are installed in the same virtual environment as LLM itself. + +You can control the set of plugins that is loaded using the `LLM_LOAD_PLUGINS` environment variable. + +Set that to the empty string to disable all plugins: + +```bash +LLM_LOAD_PLUGINS='' llm ... +``` +Or to a comma-separated list of plugin names to load only those plugins: + +```bash +LLM_LOAD_PLUGINS='llm-gpt4all,llm-cluster' llm ... +``` +You can use the `llm plugins` command to check that it is working correctly: +``` +LLM_LOAD_PLUGINS='' llm plugins +``` diff --git a/docs/plugins/llm-markov/llm_markov.py b/docs/plugins/llm-markov/llm_markov.py new file mode 100644 index 0000000..3ba4d9d --- /dev/null +++ b/docs/plugins/llm-markov/llm_markov.py @@ -0,0 +1,68 @@ +import llm +import random +import time +from typing import Optional +from pydantic import field_validator, Field + + +@llm.hookimpl +def register_models(register): + register(Markov()) + + +def build_markov_table(text): + words = text.split() + transitions = {} + # Loop through all but the last word + for i in range(len(words) - 1): + word = words[i] + next_word = words[i + 1] + transitions.setdefault(word, []).append(next_word) + return transitions + + +def generate(transitions, length, start_word=None): + all_words = list(transitions.keys()) + next_word = start_word or random.choice(all_words) + for i in range(length): + yield next_word + options = transitions.get(next_word) or all_words + next_word = random.choice(options) + + +class Markov(llm.Model): + model_id = "markov" + can_stream = True + + class Options(llm.Options): + length: Optional[int] = Field( + description="Number of words to generate", default=None + ) + delay: Optional[float] = Field( + description="Seconds to delay between each token", default=None + ) + + @field_validator("length") + def validate_length(cls, length): + if length is None: + return None + if length < 2: + raise ValueError("length must be >= 2") + return length + + @field_validator("delay") + def validate_delay(cls, delay): + if delay is None: + return None + if not 0 <= delay <= 10: + raise ValueError("delay must be between 0 and 10") + return delay + + def execute(self, prompt, stream, response, conversation): + text = prompt.prompt + transitions = build_markov_table(text) + length = prompt.options.length or 20 + for word in generate(transitions, length): + yield word + " " + if prompt.options.delay: + time.sleep(prompt.options.delay) diff --git a/docs/plugins/llm-markov/pyproject.toml b/docs/plugins/llm-markov/pyproject.toml new file mode 100644 index 0000000..f544ce2 --- /dev/null +++ b/docs/plugins/llm-markov/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "llm-markov" +version = "0.1" + +[project.entry-points.llm] +markov = "llm_markov" \ No newline at end of file diff --git a/docs/plugins/plugin-hooks.md b/docs/plugins/plugin-hooks.md new file mode 100644 index 0000000..edd3283 --- /dev/null +++ b/docs/plugins/plugin-hooks.md @@ -0,0 +1,292 @@ +(plugin-hooks)= +# Plugin hooks + +Plugins use **plugin hooks** to customize LLM's behavior. These hooks are powered by the [Pluggy plugin system](https://pluggy.readthedocs.io/). + +Each plugin can implement one or more hooks using the @hookimpl decorator against one of the hook function names described on this page. + +LLM imitates the Datasette plugin system. The [Datasette plugin documentation](https://docs.datasette.io/en/stable/writing_plugins.html) describes how plugins work. + +(plugin-hooks-register-commands)= +## register_commands(cli) + +This hook adds new commands to the `llm` CLI tool - for example `llm extra-command`. + +This example plugin adds a new `hello-world` command that prints "Hello world!": + +```python +from llm import hookimpl +import click + +@hookimpl +def register_commands(cli): + @cli.command(name="hello-world") + def hello_world(): + "Print hello world" + click.echo("Hello world!") +``` +This new command will be added to `llm --help` and can be run using `llm hello-world`. + +(plugin-hooks-register-models)= +## register_models(register, model_aliases) + +This hook can be used to register one or more additional models. + +```python +import llm + +@llm.hookimpl +def register_models(register): + register(HelloWorld()) + +class HelloWorld(llm.Model): + model_id = "helloworld" + + def execute(self, prompt, stream, response): + return ["hello world"] +``` +If your model includes an async version, you can register that too: + +```python +class AsyncHelloWorld(llm.AsyncModel): + model_id = "helloworld" + + async def execute(self, prompt, stream, response): + return ["hello world"] + +@llm.hookimpl +def register_models(register): + register(HelloWorld(), AsyncHelloWorld(), aliases=("hw",)) +``` +This demonstrates how to register a model with both sync and async versions, and how to specify an alias for that model. + +The `model_aliases` parameter is a list of {class}`~llm.ModelWithAliases` objects representing all models registered so far by other plugins. Plugins that use `@llm.hookimpl(trylast=True)` can use this to inspect or modify models registered by other plugins. Both parameters are optional - plugins can accept just `register`, just `model_aliases`, or both. + +The {ref}`model plugin tutorial ` describes how to use this hook in detail. Asynchronous models {ref}`are described here `. + +```{eval-rst} +.. autoclass:: llm.ModelWithAliases + :exclude-members: matches +``` + +(plugin-hooks-register-embedding-models)= +## register_embedding_models(register) + +This hook can be used to register one or more additional embedding models, as described in {ref}`embeddings-writing-plugins`. + +```python +import llm + +@llm.hookimpl +def register_embedding_models(register): + register(HelloWorld()) + +class HelloWorld(llm.EmbeddingModel): + model_id = "helloworld" + + def embed_batch(self, items): + return [[1, 2, 3], [4, 5, 6]] +``` + +(plugin-hooks-register-tools)= +## register_tools(register) + +This hook can register one or more tool functions for use with LLM. See {ref}`the tools documentation ` for more details. + +This example registers two tools: `upper` and `count_character_in_word`. + +```python +import llm + +def upper(text: str) -> str: + """Convert text to uppercase.""" + return text.upper() + +def count_char(text: str, character: str) -> int: + """Count the number of occurrences of a character in a word.""" + return text.count(character) + +@llm.hookimpl +def register_tools(register): + register(upper) + # Here the name= argument is used to specify a different name for the tool: + register(count_char, name="count_character_in_word") +``` + +Tools can also be implemented as classes, as described in {ref}`Toolbox classes ` in the Python API documentation. + +You can register classes like the `Memory` example {ref}`from here ` by passing the class (_not_ an instance of the class) to `register()`: + +```python +import llm + +class Memory(llm.Toolbox): + # Copy implementation from the Python API documentation + +@llm.hookimpl +def register_tools(register): + register(Memory) +``` +Once installed, this tool can be used like so: + +```bash +llm chat -T Memory +``` +If a tool name starts with a capital letter it is assumed to be a toolbox class, not a regular tool function. + +Here's an example session with the Memory tool: +``` +Chatting with gpt-4.1-mini +Type 'exit' or 'quit' to exit +Type '!multi' to enter multiple lines, then '!end' to finish +Type '!edit' to open your default editor and modify the prompt +Type '!fragment [ ...]' to insert one or more fragments +> Remember my name is Henry + +Tool call: Memory_set({'key': 'user_name', 'value': 'Henry'}) + null + +Got it, Henry! I'll remember your name. How can I assist you today? +> what keys are there? + +Tool call: Memory_keys({}) + [ + "user_name" + ] + +Currently, there is one key stored: "user_name". Would you like to add or retrieve any information? +> read it + +Tool call: Memory_get({'key': 'user_name'}) + Henry + +The value stored under the key "user_name" is Henry. Is there anything else you'd like to do? +> add Barrett to it + +Tool call: Memory_append({'key': 'user_name', 'value': 'Barrett'}) + null + +I have added "Barrett" to the key "user_name". If you want, I can now show you the updated value. +> show value + +Tool call: Memory_get({'key': 'user_name'}) + Henry + Barrett + +The value stored under the key "user_name" is now: +Henry +Barrett + +Is there anything else you would like to do? +``` + +(plugin-hooks-register-template-loaders)= +## register_template_loaders(register) + +Plugins can register new {ref}`template loaders ` using the `register_template_loaders` hook. + +Template loaders work with the `llm -t prefix:name` syntax. The prefix specifies the loader, then the registered loader function is called with the name as an argument. The loader function should return an `llm.Template()` object. + +This example plugin registers `my-prefix` as a new template loader. Once installed it can be used like this: + +```bash +llm -t my-prefix:my-template +``` +Here's the Python code: + +```python +import llm + +@llm.hookimpl +def register_template_loaders(register): + register("my-prefix", my_template_loader) + +def my_template_loader(template_path: str) -> llm.Template: + """ + Documentation for the template loader goes here. It will be displayed + when users run the 'llm templates loaders' command. + """ + try: + # Your logic to fetch the template content + # This is just an example: + prompt = "This is a sample prompt for {}".format(template_path) + system = "You are an assistant specialized in {}".format(template_path) + + # Return a Template object with the required fields + return llm.Template( + name=template_path, + prompt=prompt, + system=system, + ) + except Exception as e: + # Raise a ValueError with a clear message if the template cannot be found + raise ValueError(f"Template '{template_path}' could not be loaded: {str(e)}") +``` +The `llm.Template` class has the following constructor: + +```{eval-rst} +.. autoclass:: llm.Template +``` + +The loader function should raise a `ValueError` if the template cannot be found or loaded correctly, providing a clear error message. + +Note that `functions:` provided by templates using this plugin hook will not be made available, to avoid the risk of plugin hooks that load templates from remote sources introducing arbitrary code execution vulnerabilities. + +(plugin-hooks-register-fragment-loaders)= +## register_fragment_loaders(register) + +Plugins can register new fragment loaders using the `register_fragment_loaders` hook. These can then be used with the `llm -f prefix:argument` syntax. + +Fragment loader plugins differ from template loader plugins in that you can stack more than one fragment loader call together in the same prompt. + +A fragment loader can return one or more string fragments or attachments, or a mixture of the two. The fragments will be concatenated together into the prompt string, while any attachments will be added to the list of attachments to be sent to the model. + +The `prefix` specifies the loader. The `argument` will be passed to that registered callback.. + +The callback works in a very similar way to template loaders, but returns either a single `llm.Fragment`, a list of `llm.Fragment` objects, a single `llm.Attachment`, or a list that can mix `llm.Attachment` and `llm.Fragment` objects. + +The `llm.Fragment` constructor takes a required string argument (the content of the fragment) and an optional second `source` argument, which is a string that may be displayed as debug information. For files this is a path and for URLs it is a URL. Your plugin can use anything you like for the `source` value. + +See {ref}`the Python API documentation for attachments ` for details of the `llm.Attachment` class. + +Here is some example code: + +```python +import llm + +@llm.hookimpl +def register_fragment_loaders(register): + register("my-fragments", my_fragment_loader) + + +def my_fragment_loader(argument: str) -> llm.Fragment: + """ + Documentation for the fragment loader goes here. It will be displayed + when users run the 'llm fragments loaders' command. + """ + try: + fragment = "Fragment content for {}".format(argument) + source = "my-fragments:{}".format(argument) + return llm.Fragment(fragment, source) + except Exception as ex: + # Raise a ValueError with a clear message if the fragment cannot be loaded + raise ValueError( + f"Fragment 'my-fragments:{argument}' could not be loaded: {str(ex)}" + ) + +# Or for the case where you want to return multiple fragments and attachments: +def my_fragment_loader(argument: str) -> list[llm.Fragment]: + "Docs go here." + return [ + llm.Fragment("Fragment 1 content", "my-fragments:{argument}"), + llm.Fragment("Fragment 2 content", "my-fragments:{argument}"), + llm.Attachment(path="/path/to/image.png"), + ] +``` +A plugin like this one can be called like so: +```bash +llm -f my-fragments:argument +``` +If multiple fragments are returned they will be used as if the user passed multiple `-f X` arguments to the command. + +Multiple fragments are particularly useful for things like plugins that return every file in a directory. If these were concatenated together by the plugin, a change to a single file would invalidate the de-duplication cache for that whole fragment. Giving each file its own fragment means we can avoid storing multiple copies of that full collection if only a single file has changed. diff --git a/docs/plugins/plugin-utilities.md b/docs/plugins/plugin-utilities.md new file mode 100644 index 0000000..74e7c05 --- /dev/null +++ b/docs/plugins/plugin-utilities.md @@ -0,0 +1,92 @@ +(plugin-utilities)= +# Utility functions for plugins + +LLM provides some utility functions that may be useful to plugins. + +(plugin-utilities-get-key)= +## llm.get_key() + +This method can be used to look up secrets that users have stored using the {ref}`llm keys set ` command. If your plugin needs to access an API key or other secret this can be a convenient way to provide that. + +This returns either a string containing the key or `None` if the key could not be resolved. + +Use the `alias="name"` option to retrieve the key set with that alias: + +```python +github_key = llm.get_key(alias="github") +``` +You can also add `env="ENV_VAR"` to fall back to looking in that environment variable if the key has not been configured: +```python +github_key = llm.get_key(alias="github", env="GITHUB_TOKEN") +``` +In some cases you may allow users to provide a key as input, where they could input either the key itself or specify an alias to lookup in `keys.json`. Use the `input=` parameter for that: + +```python +github_key = llm.get_key(input=input_from_user, alias="github", env="GITHUB_TOKEN") +``` + +An previous version of function used positional arguments in a confusing order. These are still supported but the new keyword arguments are recommended as a better way to use `llm.get_key()` going forward. + +(plugin-utilities-user-dir)= +## llm.user_dir() + +LLM stores various pieces of logging and configuration data in a directory on the user's machine. + +On macOS this directory is `~/Library/Application Support/io.datasette.llm`, but this will differ on other operating systems. + +The `llm.user_dir()` function returns the path to this directory as a `pathlib.Path` object, after creating that directory if it does not yet exist. + +Plugins can use this to store their own data in a subdirectory of this directory. + +```python +import llm +user_dir = llm.user_dir() +plugin_dir = data_path = user_dir / "my-plugin" +plugin_dir.mkdir(exist_ok=True) +data_path = plugin_dir / "plugin-data.db" +``` + +(plugin-utilities-modelerror)= +## llm.ModelError + +If your model encounters an error that should be reported to the user you can raise this exception. For example: + +```python +import llm + +raise ModelError("MPT model not installed - try running 'llm mpt30b download'") +``` +This will be caught by the CLI layer and displayed to the user as an error message. + +(plugin-utilities-response-fake)= +## Response.fake() + +When writing tests for a model it can be useful to generate fake response objects, for example in this test from [llm-mpt30b](https://github.com/simonw/llm-mpt30b): + +```python +def test_build_prompt_conversation(): + model = llm.get_model("mpt") + conversation = model.conversation() + conversation.responses = [ + llm.Response.fake(model, "prompt 1", "system 1", "response 1"), + llm.Response.fake(model, "prompt 2", None, "response 2"), + llm.Response.fake(model, "prompt 3", None, "response 3"), + ] + lines = model.build_prompt(llm.Prompt("prompt 4", model), conversation) + assert lines == [ + "<|im_start|>system\system 1<|im_end|>\n", + "<|im_start|>user\nprompt 1<|im_end|>\n", + "<|im_start|>assistant\nresponse 1<|im_end|>\n", + "<|im_start|>user\nprompt 2<|im_end|>\n", + "<|im_start|>assistant\nresponse 2<|im_end|>\n", + "<|im_start|>user\nprompt 3<|im_end|>\n", + "<|im_start|>assistant\nresponse 3<|im_end|>\n", + "<|im_start|>user\nprompt 4<|im_end|>\n", + "<|im_start|>assistant\n", + ] +``` +The signature of `llm.Response.fake()` is: + +```python +def fake(cls, model: Model, prompt: str, system: str, response: str): +``` diff --git a/docs/plugins/tutorial-model-plugin.md b/docs/plugins/tutorial-model-plugin.md new file mode 100644 index 0000000..5de5e45 --- /dev/null +++ b/docs/plugins/tutorial-model-plugin.md @@ -0,0 +1,614 @@ +(tutorial-model-plugin)= + +# Developing a model plugin + +This tutorial will walk you through developing a new plugin for LLM that adds support for a new Large Language Model. + +We will be developing a plugin that implements a simple [Markov chain](https://en.wikipedia.org/wiki/Markov_chain) to generate words based on an input string. Markov chains are not technically large language models, but they provide a useful exercise for demonstrating how the LLM tool can be extended through plugins. + +(tutorial-model-plugin-initial)= + +## The initial structure of the plugin + +First create a new directory with the name of your plugin - it should be called something like `llm-markov`. +```bash +mkdir llm-markov +cd llm-markov +``` +In that directory create a file called `llm_markov.py` containing this: + +```python +import llm + +@llm.hookimpl +def register_models(register): + register(Markov()) + +class Markov(llm.Model): + model_id = "markov" + + def execute(self, prompt, stream, response, conversation): + return ["hello world"] +``` + +The `def register_models()` function here is called by the plugin system (thanks to the `@hookimpl` decorator). It uses the `register()` function passed to it to register an instance of the new model. + +The `Markov` class implements the model. It sets a `model_id` - an identifier that can be passed to `llm -m` in order to identify the model to be executed. + +The logic for executing the model goes in the `execute()` method. We'll extend this to do something more useful in a later step. + +Next, create a `pyproject.toml` file. This is necessary to tell LLM how to load your plugin: + +```toml +[project] +name = "llm-markov" +version = "0.1" + +[project.entry-points.llm] +markov = "llm_markov" +``` + +This is the simplest possible configuration. It defines a plugin name and provides an [entry point](https://setuptools.pypa.io/en/latest/userguide/entry_point.html) for `llm` telling it how to load the plugin. + +If you are comfortable with Python virtual environments you can create one now for your project, activate it and run `pip install llm` before the next step. + +If you aren't familiar with virtual environments, don't worry: you can develop plugins without them. You'll need to have LLM installed using Homebrew or `pipx` or one of the [other installation options](https://llm.datasette.io/en/latest/setup.html#installation). + +(tutorial-model-plugin-installing)= + +## Installing your plugin to try it out + +Having created a directory with a `pyproject.toml` file and an `llm_markov.py` file, you can install your plugin into LLM by running this from inside your `llm-markov` directory: + +```bash +llm install -e . +``` + +The `-e` stands for "editable" - it means you'll be able to make further changes to the `llm_markov.py` file that will be reflected without you having to reinstall the plugin. + +The `.` means the current directory. You can also install editable plugins by passing a path to their directory this: +```bash +llm install -e path/to/llm-markov +``` +To confirm that your plugin has installed correctly, run this command: +```bash +llm plugins +``` +The output should look like this: +```json +[ + { + "name": "llm-markov", + "hooks": [ + "register_models" + ], + "version": "0.1" + }, + { + "name": "llm.default_plugins.openai_models", + "hooks": [ + "register_commands", + "register_models" + ] + } +] +``` +This command lists default plugins that are included with LLM as well as new plugins that have been installed. + +Now let's try the plugin by running a prompt through it: +```bash +llm -m markov "the cat sat on the mat" +``` +It outputs: +``` +hello world +``` +Next, we'll make it execute and return the results of a Markov chain. + +(tutorial-model-plugin-building)= + +## Building the Markov chain + +Markov chains can be thought of as the simplest possible example of a generative language model. They work by building an index of words that have been seen following other words. + +Here's what that index looks like for the phrase "the cat sat on the mat" +```json +{ + "the": ["cat", "mat"], + "cat": ["sat"], + "sat": ["on"], + "on": ["the"] +} +``` +Here's a Python function that builds that data structure from a text input: +```python +def build_markov_table(text): + words = text.split() + transitions = {} + # Loop through all but the last word + for i in range(len(words) - 1): + word = words[i] + next_word = words[i + 1] + transitions.setdefault(word, []).append(next_word) + return transitions +``` +We can try that out by pasting it into the interactive Python interpreter and running this: +```pycon +>>> transitions = build_markov_table("the cat sat on the mat") +>>> transitions +{'the': ['cat', 'mat'], 'cat': ['sat'], 'sat': ['on'], 'on': ['the']} +``` + +(tutorial-model-plugin-executing)= + +## Executing the Markov chain + +To execute the model, we start with a word. We look at the options for words that might come next and pick one of those at random. Then we repeat that process until we have produced the desired number of output words. + +Some words might not have any following words from our training sentence. For our implementation we will fall back on picking a random word from our collection. + +We will implement this as a [Python generator](https://realpython.com/introduction-to-python-generators/), using the yield keyword to produce each token: +```python +def generate(transitions, length, start_word=None): + all_words = list(transitions.keys()) + next_word = start_word or random.choice(all_words) + for i in range(length): + yield next_word + options = transitions.get(next_word) or all_words + next_word = random.choice(options) +``` +If you aren't familiar with generators, the above code could also be implemented like this - creating a Python list and returning it at the end of the function: +```python +def generate_list(transitions, length, start_word=None): + all_words = list(transitions.keys()) + next_word = start_word or random.choice(all_words) + output = [] + for i in range(length): + output.append(next_word) + options = transitions.get(next_word) or all_words + next_word = random.choice(options) + return output +``` +You can try out the `generate()` function like this: +```python +lookup = build_markov_table("the cat sat on the mat") +for word in generate(transitions, 20): + print(word) +``` +Or you can generate a full string sentence with it like this: +```python +sentence = " ".join(generate(transitions, 20)) +``` + +(tutorial-model-plugin-register)= + +## Adding that to the plugin + +Our `execute()` method from earlier currently returns the list `["hello world"]`. + +Update that to use our new Markov chain generator instead. Here's the full text of the new `llm_markov.py` file: + +```python +import llm +import random + +@llm.hookimpl +def register_models(register): + register(Markov()) + +def build_markov_table(text): + words = text.split() + transitions = {} + # Loop through all but the last word + for i in range(len(words) - 1): + word = words[i] + next_word = words[i + 1] + transitions.setdefault(word, []).append(next_word) + return transitions + +def generate(transitions, length, start_word=None): + all_words = list(transitions.keys()) + next_word = start_word or random.choice(all_words) + for i in range(length): + yield next_word + options = transitions.get(next_word) or all_words + next_word = random.choice(options) + +class Markov(llm.Model): + model_id = "markov" + + def execute(self, prompt, stream, response, conversation): + text = prompt.prompt + transitions = build_markov_table(text) + for word in generate(transitions, 20): + yield word + ' ' +``` +The `execute()` method can access the text prompt that the user provided using` prompt.prompt` - `prompt` is a `Prompt` object that might include other more advanced input details as well. + +Now when you run this you should see the output of the Markov chain! +```bash +llm -m markov "the cat sat on the mat" +``` +``` +the mat the cat sat on the cat sat on the mat cat sat on the mat cat sat on +``` + + (tutorial-model-plugin-execute)= + +## Understanding execute() + +The full signature of the `execute()` method is: +```python +def execute(self, prompt, stream, response, conversation): +``` +The `prompt` argument is a `Prompt` object that contains the text that the user provided, the system prompt and the provided options. + +`stream` is a boolean that says if the model is being run in streaming mode. + +`response` is the `Response` object that is being created by the model. This is provided so you can write additional information to `response.response_json`, which may be logged to the database. + +`conversation` is the `Conversation` that the prompt is a part of - or `None` if no conversation was provided. Some models may use `conversation.responses` to access previous prompts and responses in the conversation and use them to construct a call to the LLM that includes previous context. + +(tutorial-model-plugin-logging)= + +## Prompts and responses are logged to the database + +The prompt and the response will be logged to a SQLite database automatically by LLM. You can see the single most recent addition to the logs using: +``` +llm logs -n 1 +``` +The output should look something like this: +```json +[ + { + "id": "01h52s4yez2bd1qk2deq49wk8h", + "model": "markov", + "prompt": "the cat sat on the mat", + "system": null, + "prompt_json": null, + "options_json": {}, + "response": "on the cat sat on the cat sat on the mat cat sat on the cat sat on the cat ", + "response_json": null, + "conversation_id": "01h52s4yey7zc5rjmczy3ft75g", + "duration_ms": 0, + "datetime_utc": "2023-07-11T15:29:34.685868", + "conversation_name": "the cat sat on the mat", + "conversation_model": "markov" + } +] +``` +Plugins can log additional information to the database by assigning a dictionary to the `response.response_json` property during the `execute()` method. + +Here's how to include that full `transitions` table in the `response_json` in the log: +```python + def execute(self, prompt, stream, response, conversation): + text = self.prompt.prompt + transitions = build_markov_table(text) + for word in generate(transitions, 20): + yield word + ' ' + response.response_json = {"transitions": transitions} +``` + +Now when you run the logs command you'll see that too: +```bash +llm logs -n 1 +``` +```json +[ + { + "id": 623, + "model": "markov", + "prompt": "the cat sat on the mat", + "system": null, + "prompt_json": null, + "options_json": {}, + "response": "on the mat the cat sat on the cat sat on the mat sat on the cat sat on the ", + "response_json": { + "transitions": { + "the": [ + "cat", + "mat" + ], + "cat": [ + "sat" + ], + "sat": [ + "on" + ], + "on": [ + "the" + ] + } + }, + "reply_to_id": null, + "chat_id": null, + "duration_ms": 0, + "datetime_utc": "2023-07-06T01:34:45.376637" + } +] +``` +In this particular case this isn't a great idea here though: the `transitions` table is duplicate information, since it can be reproduced from the input data - and it can get really large for longer prompts. + +(tutorial-model-plugin-options)= + +## Adding options + +LLM models can take options. For large language models these can be things like `temperature` or `top_k`. + +Options are passed using the `-o/--option` command line parameters, for example: +```bash +llm -m gpt4 "ten pet pelican names" -o temperature 1.5 +``` +We're going to add two options to our Markov chain model: + +- `length`: Number of words to generate +- `delay`: a floating point number of Delay in between output token + +The `delay` token will let us simulate a streaming language model, where tokens take time to generate and are returned by the `execute()` function as they become ready. + +Options are defined using an inner class on the model, called `Options`. It should extend the `llm.Options` class. + +First, add this import to the top of your `llm_markov.py` file: +```python +from typing import Optional +``` +Then add this `Options` class to your model: +```python +class Markov(Model): + model_id = "markov" + + class Options(llm.Options): + length: Optional[int] = None + delay: Optional[float] = None +``` +Let's add extra validation rules to our options. Length must be at least 2. Duration must be between 0 and 10. + +The `Options` class uses [Pydantic 2](https://pydantic.dev/), which can support all sorts of advanced validation rules. + +We can also add inline documentation, which can then be displayed by the `llm models --options` command. + +Add these imports to the top of `llm_markov.py`: +```python +from pydantic import field_validator, Field +``` + +We can now add Pydantic field validators for our two new rules, plus inline documentation: + +```python + class Options(llm.Options): + length: Optional[int] = Field( + description="Number of words to generate", + default=None + ) + delay: Optional[float] = Field( + description="Seconds to delay between each token", + default=None + ) + + @field_validator("length") + def validate_length(cls, length): + if length is None: + return None + if length < 2: + raise ValueError("length must be >= 2") + return length + + @field_validator("delay") + def validate_delay(cls, delay): + if delay is None: + return None + if not 0 <= delay <= 10: + raise ValueError("delay must be between 0 and 10") + return delay +``` +Lets test our options validation: +```bash +llm -m markov "the cat sat on the mat" -o length -1 +``` +``` +Error: length + Value error, length must be >= 2 +``` + +Next, we will modify our `execute()` method to handle those options. Add this to the beginning of `llm_markov.py`: +```python +import time +``` +Then replace the `execute()` method with this one: +```python + def execute(self, prompt, stream, response, conversation): + text = prompt.prompt + transitions = build_markov_table(text) + length = prompt.options.length or 20 + for word in generate(transitions, length): + yield word + ' ' + if prompt.options.delay: + time.sleep(prompt.options.delay) +``` +Add `can_stream = True` to the top of the `Markov` model class, on the line below `model_id = "markov". This tells LLM that the model is able to stream content to the console. + +The full `llm_markov.py` file should now look like this: + +```{literalinclude} llm-markov/llm_markov.py +:language: python +``` + +Now we can request a 20 word completion with a 0.1s delay between tokens like this: +```bash +llm -m markov "the cat sat on the mat" \ + -o length 20 -o delay 0.1 +``` +LLM provides a `--no-stream` option users can use to turn off streaming. Using that option causes LLM to gather the response from the stream and then return it to the console in one block. You can try that like this: +```bash +llm -m markov "the cat sat on the mat" \ + -o length 20 -o delay 0.1 --no-stream +``` +In this case it will still delay for 2s total while it gathers the tokens, then output them all at once. + +That `--no-stream` option causes the `stream` argument passed to `execute()` to be false. Your `execute()` method can then behave differently depending on whether it is streaming or not. + +Options are also logged to the database. You can see those here: +```bash +llm logs -n 1 +``` +```json +[ + { + "id": 636, + "model": "markov", + "prompt": "the cat sat on the mat", + "system": null, + "prompt_json": null, + "options_json": { + "length": 20, + "delay": 0.1 + }, + "response": "the mat on the mat on the cat sat on the mat sat on the mat cat sat on the ", + "response_json": null, + "reply_to_id": null, + "chat_id": null, + "duration_ms": 2063, + "datetime_utc": "2023-07-07T03:02:28.232970" + } +] +``` + +(tutorial-model-plugin-distributing)= + +## Distributing your plugin + +There are many different options for distributing your new plugin so other people can try it out. + +You can create a downloadable wheel or `.zip` or `.tar.gz` files, or share the plugin through GitHub Gists or repositories. + +You can also publish your plugin to PyPI, the Python Package Index. + +(tutorial-model-plugin-wheels)= + +### Wheels and sdist packages + +The easiest option is to produce a distributable package is to use the `build` command. First, install the `build` package by running this: +```bash +python -m pip install build +``` +Then run `build` in your plugin directory to create the packages: +```bash +python -m build +``` +This will create two files: `dist/llm-markov-0.1.tar.gz` and `dist/llm-markov-0.1-py3-none-any.whl`. + +Either of these files can be used to install the plugin: + +```bash +llm install dist/llm_markov-0.1-py3-none-any.whl +``` +If you host this file somewhere online other people will be able to install it using `pip install` against the URL to your package: +```bash +llm install 'https://.../llm_markov-0.1-py3-none-any.whl' +``` +You can run the following command at any time to uninstall your plugin, which is useful for testing out different installation methods: +```bash +llm uninstall llm-markov -y +``` + +(tutorial-model-plugin-gists)= + +### GitHub Gists + +A neat quick option for distributing a simple plugin is to host it in a GitHub Gist. These are available for free with a GitHub account, and can be public or private. Gists can contain multiple files but don't support directory structures - which is OK, because our plugin is just two files, `pyproject.toml` and `llm_markov.py`. + +Here's an example Gist I created for this tutorial: + +[https://gist.github.com/simonw/6e56d48dc2599bffba963cef0db27b6d](https://gist.github.com/simonw/6e56d48dc2599bffba963cef0db27b6d) + +You can turn a Gist into an installable `.zip` URL by right-clicking on the "Download ZIP" button and selecting "Copy Link". Here's that link for my example Gist: + +`https://gist.github.com/simonw/6e56d48dc2599bffba963cef0db27b6d/archive/cc50c854414cb4deab3e3ab17e7e1e07d45cba0c.zip` + +The plugin can be installed using the `llm install` command like this: +```bash +llm install 'https://gist.github.com/simonw/6e56d48dc2599bffba963cef0db27b6d/archive/cc50c854414cb4deab3e3ab17e7e1e07d45cba0c.zip' +``` + +(tutorial-model-plugin-github)= + +## GitHub repositories + +The same trick works for regular GitHub repositories as well: the "Download ZIP" button can be found by clicking the green "Code" button at the top of the repository. The URL which that provides can then be used to install the plugin that lives in that repository. + +(tutorial-model-plugin-pypi)= + +## Publishing plugins to PyPI + +The [Python Package Index (PyPI)](https://pypi.org/) is the official repository for Python packages. You can upload your plugin to PyPI and reserve a name for it - once you have done that, anyone will be able to install your plugin using `llm install `. + +Follow [these instructions](https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives) to publish a package to PyPI. The short version: +```bash +python -m pip install twine +python -m twine upload dist/* +``` +You will need an account on PyPI, then you can enter your username and password - or create a token in the PyPI settings and use `__token__` as the username and the token as the password. + +(tutorial-model-plugin-metadata)= + +## Adding metadata + +Before uploading a package to PyPI it's a good idea to add documentation and expand `pyproject.toml` with additional metadata. + +Create a `README.md` file in the root of your plugin directory with instructions about how to install, configure and use your plugin. + +You can then replace `pyproject.toml` with something like this: + +```toml +[project] +name = "llm-markov" +version = "0.1" +description = "Plugin for LLM adding a Markov chain generating model" +readme = "README.md" +authors = [{name = "Simon Willison"}] +license = {text = "Apache-2.0"} +classifiers = [ + "License :: OSI Approved :: Apache Software License" +] +dependencies = [ + "llm" +] +requires-python = ">3.7" + +[project.urls] +Homepage = "https://github.com/simonw/llm-markov" +Changelog = "https://github.com/simonw/llm-markov/releases" +Issues = "https://github.com/simonw/llm-markov/issues" + +[project.entry-points.llm] +markov = "llm_markov" +``` +This will pull in your README to be displayed as part of your project's listing page on PyPI. + +It adds `llm` as a dependency, ensuring it will be installed if someone tries to install your plugin package without it. + +It adds some links to useful pages (you can drop the `project.urls` section if those links are not useful for your project). + +You should drop a `LICENSE` file into the GitHub repository for your package as well. I like to use the Apache 2 license [like this](https://github.com/simonw/llm/blob/main/LICENSE). + +(tutorial-model-plugin-breaks)= + +## What to do if it breaks + +Sometimes you may make a change to your plugin that causes it to break, preventing `llm` from starting. For example you may see an error like this one: + +``` +$ llm 'hi' +Traceback (most recent call last): + ... + File llm-markov/llm_markov.py", line 10 + register(Markov()): + ^ +SyntaxError: invalid syntax +``` +You may find that you are unable to uninstall the plugin using `llm uninstall llm-markov` because the command itself fails with the same error. + +Should this happen, you can uninstall the plugin after first disabling it using the {ref}`LLM_LOAD_PLUGINS ` environment variable like this: +```bash +LLM_LOAD_PLUGINS='' llm uninstall llm-markov +``` diff --git a/docs/python-api.md b/docs/python-api.md new file mode 100644 index 0000000..a0929db --- /dev/null +++ b/docs/python-api.md @@ -0,0 +1,1006 @@ +(python-api)= +# Python API + +LLM provides a Python API for executing prompts, in addition to the command-line interface. + +Understanding this API is also important for writing {ref}`plugins`. + +## Basic prompt execution + +To run a prompt against the `gpt-4o-mini` model, run this: + +```python +import llm + +model = llm.get_model("gpt-4o-mini") +# key= is optional, you can configure the key in other ways +response = model.prompt( + "Five surprising names for a pet pelican", + key="sk-..." +) +print(response.text()) +``` +Note that the prompt will not be evaluated until you call that `response.text()` method - a form of lazy loading. + +If you inspect the response before it has been evaluated it will look like this: + + + +The `llm.get_model()` function accepts model IDs or aliases. You can also omit it to use the currently configured default model, which is `gpt-4o-mini` if you have not changed the default. + +In this example the key is set by Python code. You can also provide the key using the `OPENAI_API_KEY` environment variable, or use the `llm keys set openai` command to store it in a `keys.json` file, see {ref}`api-keys`. + +The `__str__()` method of `response` also returns the text of the response, so you can do this instead: + +```python +print(llm.get_model().prompt("Five surprising names for a pet pelican")) +``` + +You can run this command to see a list of available models and their aliases: + +```bash +llm models +``` +If you have set a `OPENAI_API_KEY` environment variable you can omit the `model.key = ` line. + +Calling `llm.get_model()` with an invalid model ID will raise a `llm.UnknownModelError` exception. + +(python-api-system-prompts)= + +### System prompts + +For models that accept a system prompt, pass it as `system="..."`: + +```python +response = model.prompt( + "Five surprising names for a pet pelican", + system="Answer like GlaDOS" +) +``` + +(python-api-attachments)= + +### Attachments + +Models that accept multi-modal input (images, audio, video etc) can be passed attachments using the `attachments=` keyword argument. This accepts a list of `llm.Attachment()` instances. + +This example shows two attachments - one from a file path and one from a URL: +```python +import llm + +model = llm.get_model("gpt-4o-mini") +response = model.prompt( + "Describe these images", + attachments=[ + llm.Attachment(path="pelican.jpg"), + llm.Attachment(url="https://static.simonwillison.net/static/2024/pelicans.jpg"), + ] +) +``` +Use `llm.Attachment(content=b"binary image content here")` to pass binary content directly. + +```{eval-rst} +.. autoclass:: llm.Attachment + :members: resolve_type, content_bytes, base64_content +``` + +You can check which attachment types (if any) a model supports using the `model.attachment_types` set: + +```python +model = llm.get_model("gpt-4o-mini") +print(model.attachment_types) +# {'image/gif', 'image/png', 'image/jpeg', 'image/webp'} + +if "image/jpeg" in model.attachment_types: + # Use a JPEG attachment here + ... +``` + +(python-api-tools)= + +### Tools + +{ref}`Tools ` are functions that can be executed by the model as part of a chain of responses. + +You can define tools in Python code - with a docstring to describe what they do - and then pass them to the `model.prompt()` method using the `tools=` keyword argument. If the model decides to request a tool call the `response.tool_calls()` method show what the model wants to execute: + +```python +import llm + +def upper(text: str) -> str: + """Convert text to uppercase.""" + return text.upper() + +model = llm.get_model("gpt-4.1-mini") +response = model.prompt("Convert panda to upper", tools=[upper]) +tool_calls = response.tool_calls() +# [ToolCall(name='upper', arguments={'text': 'panda'}, tool_call_id='...')] +``` +Every tool call is guaranteed to have a unique `tool_call_id`. Most providers supply their own; for providers that do not, LLM synthesizes one of the form `tc_01...`, so you can always use the id to correlate a tool call with its result or to key external state against a specific invocation. +You can call `response.execute_tool_calls()` to execute those calls and get back the results: +```python +tool_results = response.execute_tool_calls() +# [ToolResult(name='upper', output='PANDA', tool_call_id='...')] +``` +To get the model's follow-up reply, call `response.reply()` — when the previous response made tool calls, `reply()` automatically executes them and feeds the results back into the next turn: +```python +follow_up = response.reply() +print(follow_up.text()) +# The word "panda" converted to uppercase is "PANDA". +``` +You can also pass an additional user prompt: `response.reply("now translate it to French")`. To use custom or already-computed tool results (e.g. results you mutated, or synthetic ones for testing) pass them explicitly with `tool_results=` and the auto-execute step is skipped: +```python +follow_up = response.reply( + "now translate it", + tool_results=[llm.ToolResult(name="upper", output="PANDA", tool_call_id="...")], +) +``` +For an automatic loop that keeps going until the model stops requesting tools, use `model.chain()` — it passes tool call results back to the model automatically as subsequent prompts: +```python +chain_response = model.chain( + "Convert panda to upper", + tools=[upper], +) +print(chain_response.text()) +# The word "panda" converted to uppercase is "PANDA". +``` +You can also loop through the `model.chain()` response to get a stream of tokens, like this: +```python +for chunk in model.chain( + "Convert panda to upper", + tools=[upper], +): + print(chunk, end="", flush=True) +``` +This will stream each of the chain of responses in turn as they are generated. + +You can access the individual responses that make up the chain using `chain.responses()`. This can be iterated over as the chain executes like this: + +```python +chain = model.chain( + "Convert panda to upper", + tools=[upper], +) +for response in chain.responses(): + print(response.prompt) + for chunk in response: + print(chunk, end="", flush=True) +``` + +(python-api-tools-debug-hooks)= + +#### Tool debugging hooks + +Pass a function to the `before_call=` parameter of `model.chain()` to have that called before every tool call is executed. You can raise `llm.CancelToolCall()` to cancel that tool call. + +The method signature is `def before_call(tool: Optional[llm.Tool], tool_call: llm.ToolCall)` - that first `tool` argument can be `None` if the model requests a tool be executed that has not been provided in the `tools=` list. + +Here's an example: +```python +import llm +from typing import Optional + +def upper(text: str) -> str: + "Convert text to uppercase." + return text.upper() + +def before_call(tool: Optional[llm.Tool], tool_call: llm.ToolCall): + print(f"About to call tool {tool.name} with arguments {tool_call.arguments}") + if tool.name == "upper" and "bad" in repr(tool_call.arguments): + raise llm.CancelToolCall("Not allowed to call upper on text containing 'bad'") + +model = llm.get_model("gpt-4.1-mini") +response = model.chain( + "Convert panda to upper and badger to upper", + tools=[upper], + before_call=before_call, +) +print(response.text()) +``` +If you raise `llm.CancelToolCall` in the `before_call` function the model will be informed that the tool call was cancelled. + +The `after_call=` parameter can be used to run a logging function after each tool call has been executed. The method signature is `def after_call(tool: llm.Tool, tool_call: llm.ToolCall, tool_result: llm.ToolResult)`. This continues the previous example: +```python +def after_call(tool: llm.Tool, tool_call: llm.ToolCall, tool_result: llm.ToolResult): + print(f"Tool {tool.name} called with arguments {tool_call.arguments} returned {tool_result.output}") + +response = model.chain( + "Convert panda to upper and badger to upper", + tools=[upper], + after_call=after_call, +) +print(response.text()) +``` + +(python-api-tools-llm-tool-call)= + +#### Accessing the tool call from inside a tool + +Tool implementations sometimes need to know about the `llm.ToolCall` that triggered them - most often the `tool_call_id` (always populated, see above), which can be used to key external state against that specific invocation. + +If your tool function accepts a parameter named `llm_tool_call` it will be passed the `llm.ToolCall` object for the current call: + +```python +import llm + +def lookup(name: str, llm_tool_call: llm.ToolCall) -> str: + "Look up a name." + return do_lookup(name, request_id=llm_tool_call.tool_call_id) +``` + +The `llm_tool_call` parameter name is reserved: it is excluded from the input schema that is exposed to the model and is populated automatically when the tool executes. The type annotation is optional. + +This works for both sync and async tool functions, and for methods on `llm.Toolbox` subclasses. The parameter must be declared explicitly - a `**kwargs` catch-all will not receive `llm_tool_call`. + +(python-api-tools-pause)= + +#### Pausing a chain from inside a tool + +Sometimes a tool cannot finish without outside input - human approval being the classic case. Raise `llm.PauseChain` inside a tool implementation to stop the chain cleanly: + +```python +import llm + +def delete_files(path: str) -> str: + if not approval_already_recorded(path): + record_approval_request(path) + raise llm.PauseChain("waiting for approval to delete " + path) + do_delete(path) + return "deleted" +``` + +Unlike other exceptions - which are converted into `"Error: ..."` tool results and sent back to the model - `PauseChain` propagates out of the chain to your code. No provider call is made with a placeholder result. Before re-raising, the framework populates two attributes: + +- `pause.tool_call` - the `llm.ToolCall` whose implementation paused +- `pause.tool_results` - results of sibling calls in the same batch that completed + +```python +try: + chain_response.text() +except llm.PauseChain as pause: + print("Paused on", pause.tool_call.name, pause.tool_call.tool_call_id) +``` + +The failure semantics are defined: concurrent (async) sibling tool calls always run to completion before the exception propagates - their `after_call` hooks fire and their results are preserved - while sequential (sync) execution stops at the paused call, leaving later calls unexecuted so they can safely run on resume. If several concurrent calls pause, the first by call order propagates. `after_call` does not fire for a paused call, and no `ToolResult` is recorded for it - which is what marks it as still pending. + +(python-api-tools-resume)= + +#### Resuming a chain with pending tool calls + +To resume after a pause (or a crash, or a server restart), re-run the chain with a `messages=` history that ends in the unresolved tool calls. When the last assistant message in the history contains tool calls that have no matching results, the chain executes them first - through the normal `before_call`/`after_call` machinery - and then sends the results to the model: + +```python +chain = model.chain( + messages=persisted_messages, # ends in assistant tool calls with no results + tools=[delete_files], + system=system_prompt, +) +chain.text() +``` + +Calls that already have results in trailing tool-role messages are skipped, so a batch where some calls completed before the pause only re-executes the unresolved ones. A re-executed tool may raise `PauseChain` again - multi-step approval flows work by repeating the cycle. If a user or assistant message follows the tool calls in the history, the conversation has moved on and nothing is re-executed. + +Matching uses `tool_call_id` (always populated for newly-created tool calls); id-less calls from older persisted histories match results by name. You can also execute an explicit list of calls directly with `response.execute_tool_calls(tool_calls_list=[...])`. + +(python-api-tools-attachments)= + +#### Tools can return attachments + +Tools can return {ref}`attachments ` in addition to returning text. Attachments that are returned from a tool call will be passed to the model as attachments for the next prompt in the chain. + +To return one or more attachments, return a `llm.ToolOutput` instance from your tool function. This can have an `output=` string and an `attachments=` list of `llm.Attachment` instances. + +Here's an example: +```python +import llm + +def generate_image(prompt: str) -> llm.ToolOutput: + """Generate an image based on the prompt.""" + image_content = generate_image_from_prompt(prompt) + return llm.ToolOutput( + output="Image generated successfully", + attachments=[llm.Attachment( + content=image_content, + mimetype="image/png" + )], + ) +``` + +```{eval-rst} +.. autoclass:: llm.ToolOutput +``` + +(python-api-toolbox)= + +#### Toolbox classes + +Functions are useful for simple tools, but some tools may have more advanced needs. You can also define tools as a class (known as a "toolbox"), which provides the following advantages: + +- Toolbox tools can bundle multiple tools together +- Toolbox tools can be configured, e.g. to give filesystem tools access to a specific directory +- Toolbox instances can persist shared state in between tool invocations + +Toolboxes are classes that extend `llm.Toolbox`. Any methods that do not begin with an underscore will be exposed as tool functions. + +This example sets up key/value memory storage that can be used by the model: +```python +import llm + +class Memory(llm.Toolbox): + _memory = None + + def _get_memory(self): + if self._memory is None: + self._memory = {} + return self._memory + + def set(self, key: str, value: str): + "Set something as a key" + self._get_memory()[key] = value + + def get(self, key: str): + "Get something from a key" + return self._get_memory().get(key) or "" + + def append(self, key: str, value: str): + "Append something as a key" + memory = self._get_memory() + memory[key] = (memory.get(key) or "") + "\n" + value + + def keys(self): + "Return a list of keys" + return list(self._get_memory().keys()) +``` + +```{eval-rst} +.. autoclass:: llm.Toolbox + :members: tools, add_tool, prepare, prepare_async +``` + +You can then use that from Python like this: +```python +model = llm.get_model("gpt-4.1-mini") +memory = Memory() + +conversation = model.conversation(tools=[memory]) +print(conversation.chain("Set name to Simon", after_call=print).text()) + +print(memory._memory) +# Should show {'name': 'Simon'} + +print(conversation.chain("Set name to Penguin", after_call=print).text()) +# Now it should be {'name': 'Penguin'} + +print(conversation.chain("Print current name", after_call=print).text()) +``` + +See the {ref}`register_tools() plugin hook documentation ` for an example of this tool in action as a CLI plugin. + +(python-api-tools-dynamic)= +#### Dynamic toolboxes + +Sometimes you may need to register additional tools against a toolbox after it has been created - for example if you are implementing an MCP plugin where the toolbox needs to consult the MCP server to discover what tools are available. + +You can use the `toolbox.add_tool(function_or_tool)` method to add a new tool to an existing toolbox. + +This can be passed a `llm.Tool` instance or a function that will be converted into a tool automatically. + +If you want your function to be able to access the toolbox instance itself as a `self` parameter, pass that function to `add_tool()` with the `pass_self=True` parameter: + +```python +def my_function(self, arg1: str, arg2: int) -> str: + return f"Received {arg1} and {arg2} in {self}" + +toolbox.add_tool(my_function, pass_self=True) +``` +Without `pass_self=True` the function will be called with only its declared arguments, with no `self` parameter. + +If your toolbox needs to run an additional command to figure out what it should register using `.add_tool()` you can implement a `prepare()` method on your toolbox class. This will be called once automatically when the toolbox is first used. + +In asynchronous contexts the alternative method `await toolbox.prepare_async()` method will be called before the toolbox is used. You can implement this method on your subclass and use it to run asynchronous operations that discover tools to be registered using `self.add_tool()`. + +If you want to prepare the class in this way such that it can be used in both synchronous and asynchronous contexts, implement both `prepare()` and `prepare_async()` methods. + +(python-api-schemas)= + +### Schemas + +As with {ref}`the CLI tool ` some models support passing a JSON schema should be used for the resulting response. + +You can pass this to the `prompt(schema=)` parameter as either a Python dictionary or a [Pydantic](https://docs.pydantic.dev/) `BaseModel` subclass: + +```python +import llm, json +from pydantic import BaseModel + +class Dog(BaseModel): + name: str + age: int + +model = llm.get_model("gpt-4o-mini") +response = model.prompt("Describe a nice dog", schema=Dog) +dog = json.loads(response.text()) +print(dog) +# {"name":"Buddy","age":3} +``` +You can also pass a schema directly, like this: +```python +response = model.prompt("Describe a nice dog", schema={ + "properties": { + "name": {"title": "Name", "type": "string"}, + "age": {"title": "Age", "type": "integer"}, + }, + "required": ["name", "age"], + "title": "Dog", + "type": "object", +}) +``` + +You can also use LLM's {ref}`alternative schema syntax ` via the `llm.schema_dsl(schema_dsl)` function. This provides a quick way to construct a JSON schema for simple cases: + +```python +print(model.prompt( + "Describe a nice dog with a surprising name", + schema=llm.schema_dsl("name, age int, bio") +)) +``` +Pass `multi=True` to generate a schema that returns multiple items matching that specification: + +```python +print(model.prompt( + "Describe 3 nice dogs with surprising names", + schema=llm.schema_dsl("name, age int, bio", multi=True) +)) +``` + +(python-api-fragments)= + +### Fragments + +The {ref}`fragment system ` from the CLI tool can also be accessed from the Python API, by passing `fragments=` and/or `system_fragments=` lists of strings to the `prompt()` method: + +```python +response = model.prompt( + "What do these documents say about dogs?", + fragments=[ + open("dogs1.txt").read(), + open("dogs2.txt").read(), + ], + system_fragments=[ + "You answer questions like Snoopy", + ] +) +``` +This mechanism has limited utility in Python, as you can also assemble the contents of these strings together into the `prompt=` and `system=` strings directly. + +Fragments become more interesting if you are working with LLM's mechanisms for storing prompts to a SQLite database, which are not yet part of the stable, documented Python API. + +Some model plugins may include features that take advantage of fragments, for example [llm-anthropic](https://github.com/simonw/llm-anthropic) aims to use them as part of a mechanism that taps into Claude's prompt caching system. + + +(python-api-model-options)= + +### Model options + +For models that support options (view those with `llm models --options`) pass them as a dictionary to the `options=` argument of the `.prompt()` method: + +```python +model = llm.get_model() +print(model.prompt("Names for otters", options={"temperature": 0.2})) +``` + +(python-api-models-api-keys)= + +### Passing an API key + +Models that accept API keys should take an additional `key=` parameter to their `model.prompt()` method: + +```python +model = llm.get_model("gpt-4o-mini") +print(model.prompt("Names for beavers", key="sk-...")) +``` + +If you don't provide this argument LLM will attempt to find it from an environment variable (`OPENAI_API_KEY` for OpenAI, others for different plugins) or from keys that have been saved using the {ref}`llm keys set ` command. + +Some model plugins may not yet have been upgraded to handle the `key=` parameter, in which case you will need to use one of the other mechanisms. + +(python-api-models-from-plugins)= + +### Models from plugins + +Any models you have installed as plugins will also be available through this mechanism, for example to use Anthropic's Claude 3.5 Sonnet model with [llm-anthropic](https://github.com/simonw/llm-anthropic): + +```bash +pip install llm-anthropic +``` +Then in your Python code: +```python +import llm + +model = llm.get_model("claude-3.5-sonnet") +# Use this if you have not set the key using 'llm keys set claude': +model.key = 'YOUR_API_KEY_HERE' +response = model.prompt("Five surprising names for a pet pelican") +print(response.text()) +``` +Some models do not use API keys at all. + +(python-api-underlying-json)= + +### Accessing the underlying JSON + +Most model plugins also make a JSON version of the prompt response available. The structure of this will differ between model plugins, so building against this is likely to result in code that only works with that specific model provider. + +You can access this JSON data as a Python dictionary using the `response.json()` method: + +```python +import llm +from pprint import pprint + +model = llm.get_model("gpt-4o-mini") +response = model.prompt("3 names for an otter") +json_data = response.json() +pprint(json_data) +``` +Here's that example output from GPT-4o mini: +```python +{'content': 'Sure! Here are three fun names for an otter:\n' + '\n' + '1. **Splash**\n' + '2. **Bubbles**\n' + '3. **Otto** \n' + '\n' + 'Feel free to mix and match or use these as inspiration!', + 'created': 1739291215, + 'finish_reason': 'stop', + 'id': 'chatcmpl-AznO31yxgBjZ4zrzBOwJvHEWgdTaf', + 'model': 'gpt-4o-mini-2024-07-18', + 'object': 'chat.completion.chunk', + 'usage': {'completion_tokens': 43, + 'completion_tokens_details': {'accepted_prediction_tokens': 0, + 'audio_tokens': 0, + 'reasoning_tokens': 0, + 'rejected_prediction_tokens': 0}, + 'prompt_tokens': 13, + 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}, + 'total_tokens': 56}} +``` + +(python-api-token-usage)= + +### Token usage + +Many models can return a count of the number of tokens used while executing the prompt. + +The `response.usage()` method provides an abstraction over this: + +```python +pprint(response.usage()) +``` +Example output: +```python +Usage(input=5, + output=2, + details={'candidatesTokensDetails': [{'modality': 'TEXT', + 'tokenCount': 2}], + 'promptTokensDetails': [{'modality': 'TEXT', 'tokenCount': 5}]}) +``` +```{eval-rst} +.. autoclass:: llm.Usage +``` + +(python-api-streaming-responses)= + +### Streaming responses + +For models that support it you can stream responses as they are generated, like this: + +```python +response = model.prompt("Five diabolical names for a pet goat") +for chunk in response: + print(chunk, end="") +``` +The `response.text()` method described earlier does this for you - it runs through the iterator and gathers the results into a string. + +If a response has been evaluated, `response.text()` will continue to return the same string. + +```{eval-rst} +.. autoclass:: llm.Response + :members: text, json, usage, tool_calls, on_done + :exclude-members: fake, from_row, log_to_db +``` + +(python-api-messages)= + +### Structured messages and streaming events + +Many LLMs return structure that goes beyond a plain text response. LLM represents these using **messages** that consist of **parts**. + +A conversation consists of turns, where each turn is an `llm.Message` with a `role` (`"user"`, `"assistant"`, `"system"`, or `"tool"`) and a list of `Part` objects — `TextPart`, `ReasoningPart`, `ToolCallPart`, `ToolResultPart`, or `AttachmentPart`. + +You can pass structured prompt inputs via `messages=[...]`, iterate over typed events as the model streams, and inspect the assembled message after the response completes. + +Here's how to prompt a model with a list of messages instead of a plain text prompt: + +```python +import llm +from llm import user, assistant, system + +model = llm.get_model("gpt-5.4-mini") + +response = model.prompt(messages=[ + system("You are a helpful pirate."), + user("What is the capital of France?"), + assistant("Paris, matey."), + user("And Germany?"), +]) +print(response.text()) +``` + +The `user()`, `assistant()`, and `system()` helpers accept strings (wrapped as `TextPart`) but can also accept `llm.Attachment` instances (wrapped as `AttachmentPart`) or more complex sequences of `Part` objects. + +Calling `model.prompt("hi", system="Be brief.")` is equivalent to `model.prompt(messages=[system("Be brief."), user("hi")])`. + +#### Streaming events as they arrive + +`response.stream_events()` yields typed events for every content block the model produces as they stream in. This is useful for interfaces that show the model response "live". + +```python +response = model.prompt("Explain quantum computing briefly.") +for event in response.stream_events(): + if event.type == "reasoning": + print(f"[thinking] {event.chunk}", end="", flush=True) + elif event.type == "text": + print(event.chunk, end="", flush=True) + elif event.type == "tool_call_name": + print(f"\n[calling tool: {event.chunk}]") + elif event.type == "tool_call_args": + print(event.chunk, end="", flush=True) +``` + +Event types are `"text"`, `"reasoning"`, `"tool_call_name"`, `"tool_call_args"`, and `"tool_result"`. Each event carries a `part_index` that groups events into the same logical Part (all events at the same `part_index` assemble into one Part after the stream completes). For async models, use `async for event in response.astream_events()`. + +Iterating against the response object itself (`for chunk in response`) yields only text strings — reasoning and tool-call events are filtered out. + +#### Hiding reasoning output + +Some model plugins can return visible reasoning text, exposed as `"reasoning"` events from `response.stream_events()` and assembled as `ReasoningPart` objects in `response.messages()`. + +Pass `hide_reasoning=True` to ask LLM and supported model plugins not to expose that visible reasoning output: + +```python +response = model.prompt( + "Explain quantum computing briefly.", + hide_reasoning=True, +) +print(response.text()) +``` + +This is the Python API equivalent of the CLI `-R/--hide-reasoning` option. It is available on `model.prompt()`, `conversation.prompt()`, `model.chain()`, `conversation.chain()`, and their async counterparts. + +Note that this only requests that the underlying model does not return visible tokens. This request may not be supported by your provider, in which case this hint will not prevent visible reasoning tokens from being returned in the stream. + +#### Inspecting the finished response + +`response.messages()` returns the assembled list of `Message` objects produced by the model, excluding the messages from the original prompt. Calling it forces execution if the response hasn't been drained yet, so you don't need a separate `response.text()` first: + +```python +response = model.prompt("What's 2+2?") +for message in response.messages(): + for part in message.parts: + print(type(part).__name__, part.to_dict()) +``` + +On async models `messages()` is awaitable: `await response.messages()`. + +#### Persisting a conversation + +A `Response` can round-trip through a plain Python dictionary via `response.to_dict()` and `llm.Response.from_dict(...)`. The dict captures the model id, the input messages that were sent, the assistant output, and any options. The re-inflated object can be used to continue the conversation. + +Use `response.reply(...)` to continue from a rehydrated response: + +```python +import json +import llm + +model = llm.get_model("gpt-5.4-mini") +response = model.prompt("What's 2+2?") +print(response.text()) + +payload = json.dumps(response.to_dict()) +# ...save `payload` wherever you want... + +# Later — rehydrate and continue. +rebuilt = llm.Response.from_dict(json.loads(payload)) +followup = rebuilt.reply("Add 3 to that") +print(followup.text()) +``` + +`AttachmentPart` bytes are base64-encoded in the dict form, so multi-modal conversations round-trip via JSON too. + +Individual `Message` and `Part` objects also support `to_dict()` / `from_dict()` if you need to manipulate turns directly — for example, to edit, filter, or splice messages before passing them back via `model.prompt(messages=[...])`. + +(python-api-async)= + +## Async models + +Some plugins provide async versions of their supported models, suitable for use with Python [asyncio](https://docs.python.org/3/library/asyncio.html). + +To use an async model, use the `llm.get_async_model()` function instead of `llm.get_model()`: + +```python +import llm +model = llm.get_async_model("gpt-4o") +``` +You can then run a prompt using `await model.prompt(...)`: + +```python +print(await model.prompt( + "Five surprising names for a pet pelican" +).text()) +``` +Or use `async for chunk in ...` to stream the response as it is generated: +```python +async for chunk in model.prompt( + "Five surprising names for a pet pelican" +): + print(chunk, end="", flush=True) +``` + +```{eval-rst} +.. autoclass:: llm.AsyncResponse + :members: text, json, usage, tool_calls, on_done + :exclude-members: fake, from_row, log_to_db +``` + +This `await model.prompt()` method takes the same arguments as the synchronous `model.prompt()` method, for options and attachments and `key=` and suchlike. + +(python-api-async-tools)= + +### Tool functions can be sync or async + +{ref}`Tool functions ` can be both synchronous or asynchronous. The latter are defined using `async def tool_name(...)`. Either kind of function can be passed to the `tools=[...]` parameter. + +If an `async def` function is used in a synchronous context LLM will automatically execute it in a thread pool using `asyncio.run()`. This means the following will work even in non-asynchronous Python scripts: + +```python +async def hello(name: str) -> str: + "Say hello to name" + return "Hello there " + name + +model = llm.get_model("gpt-4.1-mini") +chain_response = model.chain( + "Say hello to Percival", tools=[hello] +) +print(chain_response.text()) +``` +This also works for `async def` methods of `llm.Toolbox` subclasses. + +### Tool use for async models + +Tool use is also supported for async models, using either synchronous or asynchronous tool functions. Synchronous functions will block the event loop so only use those in asynchronous context if you are certain they are extremely fast. + +The `response.execute_tool_calls()` and `chain_response.text()` and `chain_response.responses()` methods must all be awaited when run against asynchronous models: + +```python +import llm +model = llm.get_async_model("gpt-4.1") + +def upper(string): + "Converts string to uppercase" + return string.upper() + +chain = model.chain( + "Convert panda to uppercase then pelican to uppercase", + tools=[upper], + after_call=print +) +print(await chain.text()) +``` + +To iterate over the chained response output as it arrives use `async for`: +```python +async for chunk in model.chain( + "Convert panda to uppercase then pelican to uppercase", + tools=[upper] +): + print(chunk, end="", flush=True) +``` +`response.reply()` is awaitable on async models — it `await`s `execute_tool_calls()` internally before building the next turn: +```python +response = model.prompt("Convert panda to upper", tools=[upper]) +await response.text() +follow_up = await response.reply() +print(await follow_up.text()) +``` +The `before_call` and `after_call` hooks can be async functions when used with async models. + +(python-api-conversations)= + +## Conversations + +LLM supports *conversations*, where you ask follow-up questions of a model as part of an ongoing conversation. + +To start a new conversation, use the `model.conversation()` method: + +```python +model = llm.get_model() +conversation = model.conversation() +``` +You can then use the `conversation.prompt()` method to execute prompts against this conversation: + +```python +response = conversation.prompt("Five fun facts about pelicans") +print(response.text()) +``` +This works exactly the same as the `model.prompt()` method, except that the conversation will be maintained across multiple prompts. So if you run this next: +```python +response2 = conversation.prompt("Now do skunks") +print(response2.text()) +``` +You will get back five fun facts about skunks. + +The `conversation.prompt()` method supports attachments as well: +```python +response = conversation.prompt( + "Describe these birds", + attachments=[ + llm.Attachment(url="https://static.simonwillison.net/static/2024/pelicans.jpg") + ] +) +``` + +Access `conversation.responses` for a list of all of the responses that have so far been returned during the conversation. + +### Conversations using tools + +You can pass a list of tool functions to the `tools=[]` argument when you start a new conversation: +```python +import llm + +def upper(text: str) -> str: + "convert text to upper case" + return text.upper() + +def reverse(text: str) -> str: + "reverse text" + return text[::-1] + +model = llm.get_model("gpt-4.1-mini") +conversation = model.conversation(tools=[upper, reverse]) +``` +You can then call the `conversation.chain()` method multiple times to have a conversation that uses those tools: +```python +print(conversation.chain( + "Convert panda to uppercase and reverse it" +).text()) +print(conversation.chain( + "Same with pangolin" +).text()) +``` +The `before_call=` and `after_call=` parameters {ref}`described above ` can be passed directly to the `model.conversation()` method to set those options for all chained prompts in that conversation. + + +(python-api-listing-models)= + +## Listing models + +The `llm.get_models()` list returns a list of all available models, including those from plugins. + +```python +import llm + +for model in llm.get_models(): + print(model.model_id) +``` + +Use `llm.get_async_models()` to list async models: + +```python +for model in llm.get_async_models(): + print(model.model_id) +``` + +(python-api-response-on-done)= + +## Running code when a response has completed + +For some applications, such as tracking the tokens used by an application, it may be useful to execute code as soon as a response has finished being executed + +You can do this using the `response.on_done(callback)` method, which causes your callback function to be called as soon as the response has finished (all tokens have been returned). + +The signature of the method you provide is `def callback(response)` - it can be optionally an `async def` method when working with asynchronous models. + +Example usage: + +```python +import llm + +model = llm.get_model("gpt-4o-mini") +response = model.prompt("a poem about a hippo") +response.on_done(lambda response: print(response.usage())) +print(response.text()) +``` +Which outputs: +``` +Usage(input=20, output=494, details={}) +In a sunlit glade by a bubbling brook, +Lived a hefty hippo, with a curious look. +... +``` +Or using an `asyncio` model, where you need to `await response.on_done(done)` to queue up the callback: +```python +import asyncio, llm + +async def run(): + model = llm.get_async_model("gpt-4o-mini") + response = model.prompt("a short poem about a brick") + async def done(response): + print(await response.usage()) + print(await response.text()) + await response.on_done(done) + print(await response.text()) + +asyncio.run(run()) +``` + +## Other functions + +The `llm` top level package includes some useful utility functions. + +### set_alias(alias, model_id) + +The `llm.set_alias()` function can be used to define a new alias: + +```python +import llm + +llm.set_alias("mini", "gpt-4o-mini") +``` +The second argument can be a model identifier or another alias, in which case that alias will be resolved. + +If the `aliases.json` file does not exist or contains invalid JSON it will be created or overwritten. + +### remove_alias(alias) + +Removes the alias with the given name from the `aliases.json` file. + +Raises `KeyError` if the alias does not exist. + +```python +import llm + +llm.remove_alias("turbo") +``` + +### set_default_model(alias) + +This sets the default model to the given model ID or alias. Any changes to defaults will be persisted in the LLM configuration folder, and will affect all programs using LLM on the system, including the `llm` CLI tool. + +```python +import llm + +llm.set_default_model("claude-3.5-sonnet") +``` + +### get_default_model() + +This returns the currently configured default model, or `gpt-4o-mini` if no default has been set. + +```python +import llm + +model_id = llm.get_default_model() +``` + +To detect if no default has been set you can use this pattern: + +```python +if llm.get_default_model(default=None) is None: + print("No default has been set") +``` +Here the `default=` parameter specifies the value that should be returned if there is no configured default. + +### set_default_embedding_model(alias) and get_default_embedding_model() + +These two methods work the same as `set_default_model()` and `get_default_model()` but for the default {ref}`embedding model ` instead. diff --git a/docs/related-tools.md b/docs/related-tools.md new file mode 100644 index 0000000..9f5c482 --- /dev/null +++ b/docs/related-tools.md @@ -0,0 +1,58 @@ +(related-tools)= +# Related tools + +The following tools are designed to be used with LLM: + +(related-tools-strip-tags)= +## strip-tags + +[strip-tags](https://github.com/simonw/strip-tags) is a command for stripping tags from HTML. This is useful when working with LLMs because HTML tags can use up a lot of your token budget. + +Here's how to summarize the front page of the New York Times, by both stripping tags and filtering to just the elements with `class="story-wrapper"`: + +```bash +curl -s https://www.nytimes.com/ \ + | strip-tags .story-wrapper \ + | llm -s 'summarize the news' +``` + +[llm, ttok and strip-tags—CLI tools for working with ChatGPT and other LLMs](https://simonwillison.net/2023/May/18/cli-tools-for-llms/) describes ways to use `strip-tags` in more detail. + +(related-tools-ttok)= +## ttok + +[ttok](https://github.com/simonw/ttok) is a command-line tool for counting OpenAI tokens. You can use it to check if input is likely to fit in the token limit for GPT 3.5 or GPT4: + +```bash +cat my-file.txt | ttok +``` +``` +125 +``` +It can also truncate input down to a desired number of tokens: +```bash +ttok This is too many tokens -t 3 +``` +``` +This is too +``` +This is useful for truncating a large document down to a size where it can be processed by an LLM. + +(related-tools-symbex)= +## Symbex + +[Symbex](https://github.com/simonw/symbex) is a tool for searching for symbols in Python codebases. It's useful for extracting just the code for a specific problem and then piping that into LLM for explanation, refactoring or other tasks. + +Here's how to use it to find all functions that match `test*csv*` and use those to guess what the software under test does: + +```bash +symbex 'test*csv*' | \ + llm --system 'based on these tests guess what this tool does' +``` +It can also be used to export symbols in a format that can be piped to {ref}`llm embed-multi ` in order to create embeddings: +```bash +symbex '*' '*:*' --nl | \ + llm embed-multi symbols - \ + --format nl --database embeddings.db --store +``` +For more examples see [Symbex: search Python code for functions and classes, then pipe them into a LLM](https://simonwillison.net/2023/Jun/18/symbex/). diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..2238dba --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,7 @@ +sphinx==7.2.6 +furo==2023.9.10 +sphinx-autobuild +sphinx-copybutton +sphinx-markdown-builder==0.6.8 +myst-parser +cogapp diff --git a/docs/schemas.md b/docs/schemas.md new file mode 100644 index 0000000..219e3d7 --- /dev/null +++ b/docs/schemas.md @@ -0,0 +1,599 @@ +(schemas)= + +# Schemas + +Large Language Models are very good at producing structured output as JSON or other formats. LLM's **schemas** feature allows you to define the exact structure of JSON data you want to receive from a model. + +This feature is supported by models from OpenAI, Anthropic, Google Gemini and can be implemented for others {ref}`via plugins `. + +This page describes schemas used via the `llm` command-line tool. Schemas can also be used from the {ref}`Python API `. + +(schemas-tutorial)= + +## Schemas tutorial + +In this tutorial we're going to use schemas to analyze some news stories. + +But first, let's invent some dogs! + +### Getting started with dogs + +LLMs are great at creating test data. Let's define a simple schema for a dog, using LLM's {ref}`concise schema syntax `. We'll pass that to LLm with `llm --schema` and prompt it to "invent a cool dog": +```bash +llm --schema 'name, age int, one_sentence_bio' 'invent a cool dog' +``` +I got back Ziggy: +```json +{ + "name": "Ziggy", + "age": 4, + "one_sentence_bio": "Ziggy is a hyper-intelligent, bioluminescent dog who loves to perform tricks in the dark and guides his owner home using his glowing fur." +} +``` +The response matched my schema, with `name` and `one_sentence_bio` string columns and an integer for `age`. + +We're using the default LLM model here - `gpt-4o-mini`. Add `-m model` to use another model - for example use `-m o3-mini` to have O3 mini invent some dogs. + +For a list of available models that support schemas, run this command: +```bash +llm models --schemas +``` + +Want several more dogs? You can pass in that same schema using `--schema-multi` and ask for several at once: +```bash +llm --schema-multi 'name, age int, one_sentence_bio' 'invent 3 really cool dogs' +``` +Here's what I got: +```json +{ + "items": [ + { + "name": "Echo", + "age": 3, + "one_sentence_bio": "Echo is a sleek, silvery-blue Siberian Husky with mesmerizing blue eyes and a talent for mimicking sounds, making him a natural entertainer." + }, + { + "name": "Nova", + "age": 2, + "one_sentence_bio": "Nova is a vibrant, spotted Dalmatian with an adventurous spirit and a knack for agility courses, always ready to leap into action." + }, + { + "name": "Pixel", + "age": 4, + "one_sentence_bio": "Pixel is a playful, tech-savvy Poodle with a rainbow-colored coat, known for her ability to interact with smart devices and her love for puzzle toys." + } + ] +} +``` +So that's the basic idea: we can feed in a schema and LLM will pass it to the underlying model and (usually) get back JSON that conforms to that schema. + +This stuff gets a _lot_ more useful when you start applying it to larger amounts of text, extracting structured details from unstructured content. + +### Extracting people from a news articles + +We are going to extract details of the people who are mentioned in different news stories, and then use those to compile a database. + +Let's start by compiling a schema. For each person mentioned we want to extract the following details: + +- Their name +- The organization they work for +- Their role +- What we learned about them from the story + +We will also record the article headline and the publication date, to make things easier for us later on. + +Using LLM's custom, concise schema language, this time with newlines separating the individual fields (for the dogs example we used commas): +``` +name: the person's name +organization: who they represent +role: their job title or role +learned: what we learned about them from this story +article_headline: the headline of the story +article_date: the publication date in YYYY-MM-DD +``` +As you can see, this schema definition is pretty simple - each line has the name of a property we want to capture, then an optional: followed by a description, which doubles as instructions for the model. + +The full syntax is {ref}`described below ` - you can also include type information for things like numbers. + +Let's run this against a news article. + +Visit [AP News](https://apnews.com/) and grab the URL to an article. I'm using this one: + + https://apnews.com/article/trump-federal-employees-firings-a85d1aaf1088e050d39dcf7e3664bb9f + +There's quite a lot of HTML on that page, possibly even enough to exceed GPT-4o mini's 128,000 token input limit. We'll use another tool called [strip-tags](https://github.com/simonw/strip-tags) to reduce that. If you have [uv](https://docs.astral.sh/uv/) installed you can call it using `uvx strip-tags`, otherwise you'll need to install it first: + +``` +uv tool install strip-tags +# Or "pip install" or "pipx install" +``` +Now we can run this command to extract the people from that article: + +```bash +curl 'https://apnews.com/article/trump-federal-employees-firings-a85d1aaf1088e050d39dcf7e3664bb9f' | \ + uvx strip-tags | \ + llm --schema-multi " +name: the person's name +organization: who they represent +role: their job title or role +learned: what we learned about them from this story +article_headline: the headline of the story +article_date: the publication date in YYYY-MM-DD +" --system 'extract people mentioned in this article' +``` +The output I got started like this: +```json +{ + "items": [ + { + "name": "William Alsup", + "organization": "U.S. District Court", + "role": "Judge", + "learned": "He ruled that the mass firings of probationary employees were likely unlawful and criticized the authority exercised by the Office of Personnel Management.", + "article_headline": "Judge finds mass firings of federal probationary workers were likely unlawful", + "article_date": "2025-02-26" + }, + { + "name": "Everett Kelley", + "organization": "American Federation of Government Employees", + "role": "National President", + "learned": "He hailed the court's decision as a victory for employees who were illegally fired.", + "article_headline": "Judge finds mass firings of federal probationary workers were likely unlawful", + "article_date": "2025-02-26" + } +``` +This data has been logged to LLM's {ref}`SQLite database `. We can retrieve the data back out again using the {ref}`llm logs ` command like this: +```bash +llm logs -c --data +``` +The `-c` flag means "use most recent conversation", and the `--data` flag outputs just the JSON data that was captured in the response. + +We're going to want to use the same schema for other things. Schemas that we use are automatically logged to the database - we can view them using `llm schemas`: + +```bash +llm schemas +``` +Here's the output: +``` +- id: 3b7702e71da3dd791d9e17b76c88730e + summary: | + {items: [{name, organization, role, learned, article_headline, article_date}]} + usage: | + 1 time, most recently 2025-02-28T04:50:02.032081+00:00 +``` +To view the full schema, run that command with `--full`: + +```bash +llm schemas --full +``` +Which outputs: +``` +- id: 3b7702e71da3dd791d9e17b76c88730e + schema: | + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the person's name" + }, + ... +``` +That `3b7702e71da3dd791d9e17b76c88730e` ID can be used to run the same schema again. Let's try that now on a different URL: + +```bash +curl 'https://apnews.com/article/bezos-katy-perry-blue-origin-launch-4a074e534baa664abfa6538159c12987' | \ + uvx strip-tags | \ + llm --schema 3b7702e71da3dd791d9e17b76c88730e \ + --system 'extract people mentioned in this article' +``` +Here we are using `--schema` because our schema ID already corresponds to an array of items. + +The result starts like this: +```json +{ + "items": [ + { + "name": "Katy Perry", + "organization": "Blue Origin", + "role": "Singer", + "learned": "Katy Perry will join the all-female celebrity crew for a spaceflight organized by Blue Origin.", + "article_headline": "Katy Perry and Gayle King will join Jeff Bezos’ fiancee Lauren Sanchez on Blue Origin spaceflight", + "article_date": "2023-10-15" + }, +``` +One more trick: let's turn our schema and system prompt combination into a {ref}`template `. + +```bash +llm --schema 3b7702e71da3dd791d9e17b76c88730e \ + --system 'extract people mentioned in this article' \ + --save people +``` +This creates a new template called "people". We can confirm the template was created correctly using: +```bash +llm templates show people +``` +Which will output the YAML version of the template looking like this: +```yaml +name: people +schema_object: + properties: + items: + items: + properties: + article_date: + description: the publication date in YYYY-MM-DD + type: string + article_headline: + description: the headline of the story + type: string + learned: + description: what we learned about them from this story + type: string + name: + description: the person's name + type: string + organization: + description: who they represent + type: string + role: + description: their job title or role + type: string + required: + - name + - organization + - role + - learned + - article_headline + - article_date + type: object + type: array + required: + - items + type: object +system: extract people mentioned in this article +``` +We can now run our people extractor against another fresh URL. Let's use one from The Guardian: +```bash +curl https://www.theguardian.com/commentisfree/2025/feb/27/billy-mcfarland-new-fyre-festival-fantasist | \ + strip-tags | llm -t people +``` +Storing the schema in a template means we can just use `llm -t people` to run the prompt. Here's what I got back: +```json +{ + "items": [ + { + "name": "Billy McFarland", + "organization": "Fyre Festival", + "role": "Organiser", + "learned": "Billy McFarland is known for organizing the infamous Fyre Festival and was sentenced to six years in prison for wire fraud related to it. He is attempting to revive the festival with Fyre 2.", + "article_headline": "Welcome back Billy McFarland and a new Fyre festival. Shows you can’t keep a good fantasist down", + "article_date": "2025-02-27" + } + ] +} +``` +Depending on the model, schema extraction may work against images and PDF files as well. + +I took a screenshot of part of [this story in the Onion](https://theonion.com/mark-zuckerberg-insists-anyone-with-same-skewed-values-1826829272/) and saved it to the following URL: + + https://static.simonwillison.net/static/2025/onion-zuck.jpg + +We can pass that as an {ref}`attachment ` using the `-a` option. This time let's use GPT-4o: + +```bash +llm -t people -a https://static.simonwillison.net/static/2025/onion-zuck.jpg -m gpt-4o +``` +Which gave me back this: +```json +{ + "items": [ + { + "name": "Mark Zuckerberg", + "organization": "Facebook", + "role": "CEO", + "learned": "He addressed criticism by suggesting anyone with similar values and thirst for power could make the same mistakes.", + "article_headline": "Mark Zuckerberg Insists Anyone With Same Skewed Values And Unrelenting Thirst For Power Could Have Made Same Mistakes", + "article_date": "2018-06-14" + } + ] +} +``` +Now that we've extracted people from a number of different sources, let's load them into a database. + +The {ref}`llm logs ` command has several features for working with logged JSON objects. Since we've been recording multiple objects from each page in an `"items"` array using our `people` template we can access those using the following command: + +```bash +llm logs --schema t:people --data-key items +``` +In place of `t:people` we could use the `3b7702e71da3dd791d9e17b76c88730e` schema ID or even the original schema string instead, see {ref}`specifying a schema `. + +This command outputs newline-delimited JSON for every item that has been captured using the specified schema: +```json +{"name": "Katy Perry", "organization": "Blue Origin", "role": "Singer", "learned": "She is one of the passengers on the upcoming spaceflight with Blue Origin."} +{"name": "Gayle King", "organization": "Blue Origin", "role": "TV Journalist", "learned": "She is participating in the upcoming Blue Origin spaceflight."} +{"name": "Lauren Sanchez", "organization": "Blue Origin", "role": "Helicopter Pilot and former TV Journalist", "learned": "She selected the crew for the Blue Origin spaceflight."} +{"name": "Aisha Bowe", "organization": "Engineering firm", "role": "Former NASA Rocket Scientist", "learned": "She is part of the crew for the spaceflight."} +{"name": "Amanda Nguyen", "organization": "Research Scientist", "role": "Activist and Scientist", "learned": "She is included in the crew for the upcoming Blue Origin flight."} +{"name": "Kerianne Flynn", "organization": "Movie Producer", "role": "Producer", "learned": "She will also be a passenger on the upcoming spaceflight."} +{"name": "Billy McFarland", "organization": "Fyre Festival", "role": "Organiser", "learned": "He was sentenced to six years in prison for wire fraud in 2018 and has launched a new festival called Fyre 2.", "article_headline": "Welcome back Billy McFarland and a new Fyre festival. Shows you can\u2019t keep a good fantasist down", "article_date": "2025-02-27"} +{"name": "Mark Zuckerberg", "organization": "Facebook", "role": "CEO", "learned": "He attempted to dismiss criticism by suggesting that anyone with similar values and thirst for power could have made the same mistakes.", "article_headline": "Mark Zuckerberg Insists Anyone With Same Skewed Values And Unrelenting Thirst For Power Could Have Made Same Mistakes", "article_date": "2018-06-14"} +``` +If we add `--data-array` we'll get back a valid JSON array of objects instead: +```bash +llm logs --schema t:people --data-key items --data-array +``` +Output starts: +```json +[{"name": "Katy Perry", "organization": "Blue Origin", "role": "Singer", "learned": "She is one of the passengers on the upcoming spaceflight with Blue Origin."}, + {"name": "Gayle King", "organization": "Blue Origin", "role": "TV Journalist", "learned": "She is participating in the upcoming Blue Origin spaceflight."}, +``` + +We can load this into a SQLite database using [sqlite-utils](https://sqlite-utils.datasette.io/), in particular the [sqlite-utils insert](https://sqlite-utils.datasette.io/en/stable/cli.html#inserting-json-data) command. + +```bash +uv tool install sqlite-utils +# or pip install or pipx install +``` +Now we can pipe the JSON into that tool to create a database with a `people` table: +```bash +llm logs --schema t:people --data-key items --data-array | \ + sqlite-utils insert data.db people - +``` +To see a table of the name, organization and role columns use [sqlite-utils rows](https://sqlite-utils.datasette.io/en/stable/cli.html#returning-all-rows-in-a-table): +```bash +sqlite-utils rows data.db people -t -c name -c organization -c role +``` +Which produces: +``` +name organization role +--------------- ------------------ ----------------------------------------- +Katy Perry Blue Origin Singer +Gayle King Blue Origin TV Journalist +Lauren Sanchez Blue Origin Helicopter Pilot and former TV Journalist +Aisha Bowe Engineering firm Former NASA Rocket Scientist +Amanda Nguyen Research Scientist Activist and Scientist +Kerianne Flynn Movie Producer Producer +Billy McFarland Fyre Festival Organiser +Mark Zuckerberg Facebook CEO +``` +We can also explore the database in a web interface using [Datasette](https://datasette.io/): + +```bash +uvx datasette data.db +# Or install datasette first: +uv tool install datasette # or pip install or pipx install +datasette data.db +``` +Visit `http://127.0.0.1:8001/data/people` to start navigating the data. + +(schemas-json-schemas)= + +## Using JSON schemas + +The above examples have both used {ref}`concise schema syntax `. LLM converts this format to [JSON schema](https://json-schema.org/), and you can use JSON schema directly yourself if you wish. + +JSON schema covers the following: + +- The data types of fields (string, number, array, object, etc.) +- Required vs. optional fields +- Nested data structures +- Constraints on values (minimum/maximum, patterns, etc.) +- Descriptions of those fields - these can be used to guide the language model + +Different models may support different subsets of the overall JSON schema language. You should experiment to figure out what works for the model you are using. + +LLM recommends that the top level of the schema is an object, not an array, for increased compatibility across multiple models. I suggest using `{"items": [array of objects]}` if you want to return an array. + +The dogs schema above, `name, age int, one_sentence_bio`, would look like this as a full JSON schema: + +```json +{ + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "integer" + }, + "one_sentence_bio": { + "type": "string" + } + }, + "required": [ + "name", + "age", + "one_sentence_bio" + ] +} +``` +This JSON can be passed directly to the `--schema` option, or saved in a file and passed as the filename. +```bash +llm --schema '{ + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "integer" + }, + "one_sentence_bio": { + "type": "string" + } + }, + "required": [ + "name", + "age", + "one_sentence_bio" + ] +}' 'a surprising dog' +``` +Example output: +```json +{ + "name": "Baxter", + "age": 3, + "one_sentence_bio": "Baxter is a rescue dog who learned to skateboard and now performs tricks at local parks, astonishing everyone with his skill!" +} +``` + +(schemas-specify)= + +## Ways to specify a schema + +LLM accepts schema definitions for both running prompts and exploring logged responses, using the `--schema` option. + +This option can take multiple forms: + +- A string providing a JSON schema: `--schema '{"type": "object", ...}'` +- A {ref}`condensed schema definition `: `--schema 'name,age int'` +- The name or path of a file on disk containing a JSON schema: `--schema dogs.schema.json` +- The hexadecimal ID of a previously logged schema: `--schema 520f7aabb121afd14d0c6c237b39ba2d` - these IDs can be found using the `llm schemas` command. +- A schema that has been {ref}`saved in a template `: `--schema t:name-of-template`, see {ref}`schemas-reusable`. + +(schemas-dsl)= + +## Concise LLM schema syntax + +JSON schema's can be time-consuming to construct by hand. LLM also supports a concise alternative syntax for specifying a schema. + +A simple schema for an object with two string properties called `name` and `bio` looks like this: + + name, bio + +You can include type information by adding a type indicator after the property name, separated by a space. + + name, bio, age int + +Supported types are `int` for integers, `float` for floating point numbers, `str` for strings (the default) and `bool` for true/false booleans. + +To include a description of the field to act as a hint to the model, add one after a colon: + + name: the person's name, age int: their age, bio: a short bio + +If your schema is getting long you can switch from comma-separated to newline-separated, which also allows you to use commas in those descriptions: + + name: the person's name + age int: their age + bio: a short bio, no more than three sentences + +You can experiment with the syntax using the `llm schemas dsl` command, which converts the input into a JSON schema: +```bash +llm schemas dsl 'name, age int' +``` +Output: +```json +{ + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "integer" + } + }, + "required": [ + "name", + "age" + ] +} +``` + +The Python utility function `llm.schema_dsl(schema)` can be used to convert this syntax into the equivalent JSON schema dictionary when working with schemas {ref}`in the Python API `. + +(schemas-reusable)= + +## Saving reusable schemas in templates + +If you want to store a schema with a name so you can reuse it easily in the future, the easiest way to do so is to save it {ref}`in a template `. + +The quickest way to do that is with the `llm --save` option: + +```bash +llm --schema 'name, age int, one_sentence_bio' --save dog +``` +Now you can use it like this: +```bash +llm --schema t:dog 'invent a dog' +``` +Or: +```bash +llm --schema-multi t:dog 'invent three dogs' +``` +(schemas-logs)= + +## Browsing logged JSON objects created using schemas + +By default, all JSON produced using schemas is logged to {ref}`a SQLite database `. You can use special options to the `llm logs` command to extract just those JSON objects in a useful format. + +The `llm logs --schema X` filter option can be used to filter just for responses that were created using the specified schema. You can pass the full schema JSON, a path to the schema on disk or the schema ID. + +The `--data` option causes just the JSON data collected by that schema to be outputted, as newline-delimited JSON. + +If you instead want a JSON array of objects (with starting and ending square braces) you can use `--data-array` instead. + +Let's invent some dogs: + +```bash +llm --schema-multi 'name, ten_word_bio' 'invent 3 cool dogs' +llm --schema-multi 'name, ten_word_bio' 'invent 2 cool dogs' +``` +Having logged these cool dogs, you can see just the data that was returned by those prompts like this: +```bash +llm logs --schema-multi 'name, ten_word_bio' --data +``` +We need to use `--schema-multi` here because we used that when we first created these records. The `--schema` option is also supported, and can be passed a filename or JSON schema or schema ID as well. + +Output: +``` +{"items": [{"name": "Robo", "ten_word_bio": "A cybernetic dog with laser eyes and super intelligence."}, {"name": "Flamepaw", "ten_word_bio": "Fire-resistant dog with a talent for agility and tricks."}]} +{"items": [{"name": "Bolt", "ten_word_bio": "Lightning-fast border collie, loves frisbee and outdoor adventures."}, {"name": "Luna", "ten_word_bio": "Mystical husky with mesmerizing blue eyes, enjoys snow and play."}, {"name": "Ziggy", "ten_word_bio": "Quirky pug who loves belly rubs and quirky outfits."}]} +``` +Note that the dogs are nested in that `"items"` key. To access the list of items from that key use `--data-key items`: +```bash +llm logs --schema-multi 'name, ten_word_bio' --data-key items +``` +Output: +``` +{"name": "Bolt", "ten_word_bio": "Lightning-fast border collie, loves frisbee and outdoor adventures."} +{"name": "Luna", "ten_word_bio": "Mystical husky with mesmerizing blue eyes, enjoys snow and play."} +{"name": "Ziggy", "ten_word_bio": "Quirky pug who loves belly rubs and quirky outfits."} +{"name": "Robo", "ten_word_bio": "A cybernetic dog with laser eyes and super intelligence."} +{"name": "Flamepaw", "ten_word_bio": "Fire-resistant dog with a talent for agility and tricks."} +``` +Finally, to output a JSON array instead of newline-delimited JSON use `--data-array`: +```bash +llm logs --schema-multi 'name, ten_word_bio' --data-key items --data-array +``` +Output: +```json +[{"name": "Bolt", "ten_word_bio": "Lightning-fast border collie, loves frisbee and outdoor adventures."}, + {"name": "Luna", "ten_word_bio": "Mystical husky with mesmerizing blue eyes, enjoys snow and play."}, + {"name": "Ziggy", "ten_word_bio": "Quirky pug who loves belly rubs and quirky outfits."}, + {"name": "Robo", "ten_word_bio": "A cybernetic dog with laser eyes and super intelligence."}, + {"name": "Flamepaw", "ten_word_bio": "Fire-resistant dog with a talent for agility and tricks."}] +``` +Add `--data-ids` to include `"response_id"` and `"conversation_id"` fields in each of the returned objects reflecting the database IDs of the response and conversation they were a part of. This can be useful for tracking the source of each individual row. + +```bash +llm logs --schema-multi 'name, ten_word_bio' --data-key items --data-ids +``` +Output: +```json +{"name": "Nebula", "ten_word_bio": "A cosmic puppy with starry fur, loves adventures in space.", "response_id": "01jn4dawj8sq0c6t3emf4k5ryx", "conversation_id": "01jn4dawj8sq0c6t3emf4k5ryx"} +{"name": "Echo", "ten_word_bio": "A clever hound with extraordinary hearing, master of hide-and-seek.", "response_id": "01jn4dawj8sq0c6t3emf4k5ryx", "conversation_id": "01jn4dawj8sq0c6t3emf4k5ryx"} +{"name": "Biscuit", "ten_word_bio": "An adorable chef dog, bakes treats that everyone loves.", "response_id": "01jn4dawj8sq0c6t3emf4k5ryx", "conversation_id": "01jn4dawj8sq0c6t3emf4k5ryx"} +{"name": "Cosmo", "ten_word_bio": "Galactic explorer, loves adventures and chasing shooting stars.", "response_id": "01jn4daycb3svj0x7kvp7zrp4q", "conversation_id": "01jn4daycb3svj0x7kvp7zrp4q"} +{"name": "Pixel", "ten_word_bio": "Tech-savvy pup, builds gadgets and loves virtual playtime.", "response_id": "01jn4daycb3svj0x7kvp7zrp4q", "conversation_id": "01jn4daycb3svj0x7kvp7zrp4q"} +``` +If a row already has a property called `"conversation_id"` or `"response_id"` additional underscores will be appended to the ID key until it no longer overlaps with the existing keys. + +The `--id-gt $ID` and `--id-gte $ID` options can be useful for ignoring logged schema data prior to a certain point, see {ref}`logging-filter-id` for details. \ No newline at end of file diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..72801ba --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,208 @@ +# Setup + +## Installation + +Install this tool using `pip`: +```bash +pip install llm +``` +Or using [pipx](https://pypa.github.io/pipx/): +```bash +pipx install llm +``` +Or using [uv](https://docs.astral.sh/uv/guides/tools/) ({ref}`more tips below `): +```bash +uv tool install llm +``` +Or using [Homebrew](https://brew.sh/) (see {ref}`warning note `): +```bash +brew install llm +``` + +## Upgrading to the latest version + +If you installed using `pip`: +```bash +pip install -U llm +``` +For `pipx`: +```bash +pipx upgrade llm +``` +For `uv`: +```bash +uv tool upgrade llm +``` +For Homebrew: +```bash +brew upgrade llm +``` +If the latest version is not yet available on Homebrew you can upgrade like this instead: +```bash +llm install -U llm +``` + +(setup-uvx)= +## Using uvx + +If you have [uv](https://docs.astral.sh/uv/) installed you can also use the `uvx` command to try LLM without first installing it like this: + +```bash +export OPENAI_API_KEY='sx-...' +uvx llm 'fun facts about skunks' +``` +This will install and run LLM using a temporary virtual environment. + +You can use the `--with` option to add extra plugins. To use Anthropic's models, for example: +```bash +export ANTHROPIC_API_KEY='...' +uvx --with llm-anthropic llm -m claude-3.5-haiku 'fun facts about skunks' +``` +All of the usual LLM commands will work with `uvx llm`. Here's how to set your OpenAI key without needing an environment variable for example: +```bash +uvx llm keys set openai +# Paste key here +``` + +(homebrew-warning)= +## A note about Homebrew and PyTorch + +The version of LLM packaged for Homebrew currently uses Python 3.12. The PyTorch project do not yet have a stable release of PyTorch for that version of Python. + +This means that LLM plugins that depend on PyTorch such as [llm-sentence-transformers](https://github.com/simonw/llm-sentence-transformers) may not install cleanly with the Homebrew version of LLM. + +You can workaround this by manually installing PyTorch before installing `llm-sentence-transformers`: + +```bash +llm install llm-python +llm python -m pip install \ + --pre torch torchvision \ + --index-url https://download.pytorch.org/whl/nightly/cpu +llm install llm-sentence-transformers +``` +This should produce a working installation of that plugin. + +## Installing plugins + +{ref}`plugins` can be used to add support for other language models, including models that can run on your own device. + +For example, the [llm-gpt4all](https://github.com/simonw/llm-gpt4all) plugin adds support for 17 new models that can be installed on your own machine. You can install that like so: +```bash +llm install llm-gpt4all +``` + +(api-keys)= +## API key management + +Many LLM models require an API key. These API keys can be provided to this tool using several different mechanisms. + +You can obtain an API key for OpenAI's language models from [the API keys page](https://platform.openai.com/api-keys) on their site. + +### Saving and using stored keys + +The easiest way to store an API key is to use the `llm keys set` command: + +```bash +llm keys set openai +``` +You will be prompted to enter the key like this: +``` +% llm keys set openai +Enter key: +``` +Once stored, this key will be automatically used for subsequent calls to the API: + +```bash +llm "Five ludicrous names for a pet lobster" +``` + +You can list the names of keys that have been set using this command: + +```bash +llm keys +``` + +Keys that are stored in this way live in a file called `keys.json`. This file is located at the path shown when you run the following command: + +```bash +llm keys path +``` + +On macOS this will be `~/Library/Application Support/io.datasette.llm/keys.json`. On Linux it may be something like `~/.config/io.datasette.llm/keys.json`. + +### Passing keys using the --key option + +Keys can be passed directly using the `--key` option, like this: + +```bash +llm "Five names for pet weasels" --key sk-my-key-goes-here +``` +You can also pass the alias of a key stored in the `keys.json` file. For example, if you want to maintain a personal API key you could add that like this: +```bash +llm keys set personal +``` +And then use it for prompts like so: + +```bash +llm "Five friendly names for a pet skunk" --key personal +``` + +### Keys in environment variables + +Keys can also be set using an environment variable. These are different for different models. + +For OpenAI models the key will be read from the `OPENAI_API_KEY` environment variable. + +The environment variable will be used if no `--key` option is passed to the command and there is not a key configured in `keys.json` + +To use an environment variable in place of the `keys.json` key run the prompt like this: +```bash +llm 'my prompt' --key $OPENAI_API_KEY +``` + +## Configuration + +You can configure LLM in a number of different ways. + +(setup-default-model)= +### Setting a custom default model + +The model used when calling `llm` without the `-m/--model` option defaults to `gpt-4o-mini` - the fastest and least expensive OpenAI model. + +You can use the `llm models default` command to set a different default model. For GPT-4o (slower and more expensive, but more capable) run this: + +```bash +llm models default gpt-4o +``` +You can view the current model by running this: +``` +llm models default +``` +Any of the supported aliases for a model can be passed to this command. + +### Setting a custom directory location + +This tool stores various files - prompt templates, stored keys, preferences, a database of logs - in a directory on your computer. + +On macOS this is `~/Library/Application Support/io.datasette.llm/`. + +On Linux it may be something like `~/.config/io.datasette.llm/`. + +You can set a custom location for this directory by setting the `LLM_USER_PATH` environment variable: + +```bash +export LLM_USER_PATH=/path/to/my/custom/directory +``` +### Turning SQLite logging on and off + +By default, LLM will log every prompt and response you make to a SQLite database - see {ref}`logging` for more details. + +You can turn this behavior off by default by running: +```bash +llm logs off +``` +Or turn it back on again with: +``` +llm logs on +``` +Run `llm logs status` to see the current states of the setting. \ No newline at end of file diff --git a/docs/templates.md b/docs/templates.md new file mode 100644 index 0000000..3757af4 --- /dev/null +++ b/docs/templates.md @@ -0,0 +1,387 @@ +(prompt-templates)= +# Templates + +A **template** can combine a prompt, system prompt, model, default model options, schema, and fragments into a single reusable unit. + +Only one template can be used at a time. To compose multiple shorter pieces of prompts together consider using {ref}`fragments ` instead. + +(prompt-templates-save)= + +## Getting started with --save + +The easiest way to create a template is using the `--save template_name` option. + +Here's how to create a template for summarizing text: + +```bash +llm '$input - summarize this' --save summarize +``` +Put `$input` where you would like the user's input to be inserted. If you omit this their input will be added to the end of your regular prompt: +```bash +llm 'Summarize the following: ' --save summarize +``` +You can also create templates using system prompts: +```bash +llm --system 'Summarize this' --save summarize +``` +You can set the default model for a template using `--model`: + +```bash +llm --system 'Summarize this' --model gpt-4o --save summarize +``` +You can also save default options: +```bash +llm --system 'Speak in French' -o temperature 1.8 --save wild-french +``` +If you want to include a literal `$` sign in your prompt, use `$$` instead: +```bash +llm --system 'Estimate the cost in $$ of this: $input' --save estimate +``` +Use `--tool/-T` one or more times to add tools to the template: +```bash +llm -T llm_time --system 'Always include the current time in the answer' --save time +``` +You can also use `--functions` to add Python function code directly to the template: +```bash +llm --functions 'def reverse_string(s): return s[::-1]' --system 'reverse any input' --save reverse +llm -t reverse 'Hello, world!' +``` + +Add `--schema` to bake a {ref}`schema ` into your template: + +```bash +llm --schema dog.schema.json 'invent a dog' --save dog +``` + +If you add `--extract` the setting to {ref}`extract the first fenced code block ` will be persisted in the template. +```bash +llm --system 'write a Python function' --extract --save python-function +llm -t python-function 'calculate haversine distance between two points' +``` +In each of these cases the template will be saved in YAML format in a dedicated directory on disk. + +(prompt-templates-using)= + +## Using a template + +You can execute a named template using the `-t/--template` option: + +```bash +curl -s https://example.com/ | llm -t summarize +``` + +This can be combined with the `-m` option to specify a different model: +```bash +curl -s https://llm.datasette.io/en/latest/ | \ + llm -t summarize -m gpt-3.5-turbo-16k +``` +Templates can also be specified as a direct path to a YAML file on disk: +```bash +llm -t path/to/template.yaml 'extra prompt here' +``` +Or as a URL to a YAML file hosted online: +```bash +llm -t https://raw.githubusercontent.com/simonw/llm-templates/refs/heads/main/python-app.yaml \ + 'Python app to pick a random line from a file' +``` +Note that templates loaded via URLs will have any `functions:` keys ignored, to avoid accidentally executing arbitrary code. This restriction also applies to templates loaded via the {ref}`template loaders plugin mechanism `. + +(prompt-templates-list)= + +## Listing available templates + +This command lists all available templates: +```bash +llm templates +``` +The output looks something like this: +``` +cmd : system: reply with macos terminal commands only, no extra information +glados : system: You are GlaDOS prompt: Summarize this: +``` + +(prompt-templates-yaml)= + +## Templates as YAML files + +Templates are stored as YAML files on disk. + +You can edit (or create) a YAML file for a template using the `llm templates edit` command: +``` +llm templates edit summarize +``` +This will open the system default editor. + +:::{tip} +You can control which editor will be used here using the `EDITOR` environment variable - for example, to use VS Code: +```bash +export EDITOR="code -w" +``` +Add that to your `~/.zshrc` or `~/.bashrc` file depending on which shell you use (`zsh` is the default on macOS since macOS Catalina in 2019). +::: + +You can create or edit template files directly in the templates directory. The location of this directory is shown by the `llm templates path` command: +```bash +llm templates path +``` +Example output: +``` +/Users/simon/Library/Application Support/io.datasette.llm/templates +``` + +A basic YAML template looks like this: + +```yaml +prompt: 'Summarize this: $input' +``` +Or use YAML multi-line strings for longer inputs. I created this using `llm templates edit steampunk`: +```yaml +prompt: > + Summarize the following text. + + Insert frequent satirical steampunk-themed illustrative anecdotes. + Really go wild with that. + + Text to summarize: $input +``` +The `prompt: >` causes the following indented text to be treated as a single string, with newlines collapsed to spaces. Use `prompt: |` to preserve newlines. + +Running that with `llm -t steampunk` against GPT-4o (via [strip-tags](https://github.com/simonw/strip-tags) to remove HTML tags from the input and minify whitespace): +```bash +curl -s 'https://til.simonwillison.net/macos/imovie-slides-and-audio' | \ + strip-tags -m | llm -t steampunk -m gpt-4o +``` +Output: +> In a fantastical steampunk world, Simon Willison decided to merge an old MP3 recording with slides from the talk using iMovie. After exporting the slides as images and importing them into iMovie, he had to disable the default Ken Burns effect using the "Crop" tool. Then, Simon manually synchronized the audio by adjusting the duration of each image. Finally, he published the masterpiece to YouTube, with the whimsical magic of steampunk-infused illustrations leaving his viewers in awe. + +(prompt-templates-system)= + +### System prompts + +When working with models that support system prompts you can set a system prompt using a `system:` key like so: + +```yaml +system: Summarize this +``` +If you specify only a system prompt you don't need to use the `$input` variable - `llm` will use the user's input as the whole of the regular prompt, which will then be processed using the instructions set in that system prompt. + +You can combine system and regular prompts like so: + +```yaml +system: You speak like an excitable Victorian adventurer +prompt: 'Summarize this: $input' +``` + +(prompt-templates-fragments)= + +### Fragments + +Templates can reference {ref}`Fragments ` using the `fragments:` and `system_fragments:` keys. These should be a list of fragment URLs, filepaths or hashes: + +```yaml +fragments: +- https://example.com/robots.txt +- /path/to/file.txt +- 993fd38d898d2b59fd2d16c811da5bdac658faa34f0f4d411edde7c17ebb0680 +system_fragments: +- https://example.com/systm-prompt.txt +``` + +(prompt-templates-options)= + +### Options + +Default options can be set using the `options:` key: + +```yaml +name: wild-french +system: Speak in French +options: + temperature: 1.8 +``` + +(prompt-templates-tools)= + +### Tools + +The `tools:` key can provide a list of tool names from other plugins - either function names or toolbox specifiers: +```yaml +name: time-plus +tools: +- llm_time +- Datasette("https://example.com/timezone-lookup") +``` +The `functions:` key can provide a multi-line string of Python code defining additional functions: +```yaml +name: my-functions +functions: | + def reverse_string(s: str): + return s[::-1] + + def greet(name: str): + return f"Hello, {name}!" +``` +(prompt-templates-schemas)= + +### Schemas + +Use the `schema_object:` key to embed a JSON schema (as YAML) in your template. The easiest way to create these is with the `llm --schema ... --save name-of-template` command - the result should look something like this: + +```yaml +name: dogs +schema_object: + properties: + dogs: + items: + properties: + bio: + type: string + name: + type: string + type: object + type: array + type: object +``` + +(prompt-templates-variables)= + +### Additional template variables + +Templates that work against the user's normal prompt input (content that is either piped to the tool via standard input or passed as a command-line argument) can use the `$input` variable. + +You can use additional named variables. These will then need to be provided using the `-p/--param` option when executing the template. + +Here's an example YAML template called `recipe`, which you can create using `llm templates edit recipe`: + +```yaml +prompt: | + Suggest a recipe using ingredients: $ingredients + + It should be based on cuisine from this country: $country +``` +This can be executed like so: + +```bash +llm -t recipe -p ingredients 'sausages, milk' -p country Germany +``` +My output started like this: +> Recipe: German Sausage and Potato Soup +> +> Ingredients: +> - 4 German sausages +> - 2 cups whole milk + +This example combines input piped to the tool with additional parameters. Call this `summarize`: + +```yaml +system: Summarize this text in the voice of $voice +``` +Then to run it: +```bash +curl -s 'https://til.simonwillison.net/macos/imovie-slides-and-audio' | \ + strip-tags -m | llm -t summarize -p voice GlaDOS +``` +I got this: + +> My previous test subject seemed to have learned something new about iMovie. They exported keynote slides as individual images [...] Quite impressive for a human. + +(prompt-default-parameters)= + +### Specifying default parameters + +When creating a template using the `--save` option you can pass `-p name value` to store the default values for parameters: +```bash +llm --system 'Summarize this text in the voice of $voice' \ + --model gpt-4o -p voice GlaDOS --save summarize +``` + +You can specify default values for parameters in the YAML using the `defaults:` key. + +```yaml +system: Summarize this text in the voice of $voice +defaults: + voice: GlaDOS +``` + +When running without `-p` it will choose the default: + +```bash +curl -s 'https://til.simonwillison.net/macos/imovie-slides-and-audio' | \ + strip-tags -m | llm -t summarize +``` + +But you can override the defaults with `-p`: + +```bash +curl -s 'https://til.simonwillison.net/macos/imovie-slides-and-audio' | \ + strip-tags -m | llm -t summarize -p voice Yoda +``` + +I got this: + +> Text, summarize in Yoda's voice, I will: "Hmm, young padawan. Summary of this text, you seek. Hmmm. ... + +(prompt-templates-extract)= + +### Configuring code extraction + +To configure the {ref}`extract first fenced code block ` setting for the template, add this: + +```yaml +extract: true +``` + +(prompt-templates-default-model)= + +### Setting a default model for a template + +Templates executed using `llm -t template-name` will execute using the default model that the user has configured for the tool - or `gpt-3.5-turbo` if they have not configured their own default. + +You can specify a new default model for a template using the `model:` key in the associated YAML. Here's a template called `roast`: + +```yaml +model: gpt-4o +system: roast the user at every possible opportunity, be succinct +``` +Example: +```bash +llm -t roast 'How are you today?' +``` +> I'm doing great but with your boring questions, I must admit, I've seen more life in a cemetery. + +(prompt-templates-loaders)= + +## Template loaders from plugins + +LLM plugins can {ref}`register prefixes ` that can be used to load templates from external sources. + +[llm-templates-github](https://github.com/simonw/llm-templates-github) is an example which adds a `gh:` prefix which can be used to load templates from GitHub. + +You can install that plugin like this: +```bash +llm install llm-templates-github +``` + +Use the `llm templates loaders` command to see details of the registered loaders. + +```bash +llm templates loaders +``` +Output: +``` +gh: + Load a template from GitHub or local cache if available + + Format: username/repo/template_name (without the .yaml extension) + or username/template_name which means username/llm-templates/template_name +``` + +Then you can then use it like this: +```bash +curl -sL 'https://llm.datasette.io/' | llm -t gh:simonw/summarize +``` +The `-sL` flags to `curl` are used to follow redirects and suppress progress meters. + +This command will fetch the content of the LLM index page and feed it to the template defined by [summarize.yaml](https://github.com/simonw/llm-templates/blob/main/summarize.yaml) in the [simonw/llm-templates](https://github.com/simonw/llm-templates) GitHub repository. + +If two template loader plugins attempt to register the same prefix one of them will have `_1` added to the end of their prefix. Use `llm templates loaders` to check if this has occurred. \ No newline at end of file diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..bc37078 --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,102 @@ +(tools)= + +# Tools + +Many Large Language Models have been trained to execute tools as part of responding to a prompt. LLM supports tool usage with both the command-line interface and the Python API. + +Exposing tools to LLMs **carries risks**! Be sure to read the {ref}`warning below `. + +(tools-how-they-work)= + +## How tools work + +A tool is effectively a function that the model can request to be executed. Here's how that works: + +1. The initial prompt to the model includes a list of available tools, containing their names, descriptions and parameters. +2. The model can choose to call one (or sometimes more than one) of those tools, returning a request for the tool to execute. +3. The code that calls the model - in this case LLM itself - then executes the specified tool with the provided arguments. +4. LLM prompts the model a second time, this time including the output of the tool execution. +5. The model can then use that output to generate its next response. + +This sequence can run several times in a loop, allowing the LLM to access data, act on that data and then pass that data off to other tools for further processing. + +:::{admonition} Tools can be dangerous +:class: danger + +(tools-warning)= + +## Warning: Tools can be dangerous + +Applications built on top of LLMs suffer from a class of attacks called [prompt injection](https://simonwillison.net/tags/prompt-injection/) attacks. These occur when a malicious third party injects content into the LLM which causes it to take tool-based actions that act against the interests of the user of that application. + +Be very careful about which tools you enable when you potentially might be exposed to untrusted sources of content - web pages, GitHub issues posted by other people, email and messages that have been sent to you that could come from an attacker. + +Watch out for [the lethal trifecta](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/) of prompt injection exfiltration attacks. If your tool-enabled LLM has the following: + +- access to private data +- exposure to malicious instructions +- the ability to exfiltrate information + +Anyone who can feed malicious instructions into your LLM - by leaving them on a web page it visits, or sending an email to an inbox that it monitors - could be able to trick your LLM into using other tools to access your private information and then exfiltrate (pass out) that data to somewhere the attacker can see it. +::: + +(tools-trying-out)= + +## Trying out tools + +LLM comes with a default tool installed, called `llm_version`. You can try that out like this: + +```bash +llm --tool llm_version "What version of LLM is this?" --td +``` +You can also use `-T llm_version` as a shortcut for `--tool llm_version`. + +The output should look like this: +``` +Tool call: llm_version({}) + 0.26a0 + +The installed version of the LLM is 0.26a0. +``` +Further tools can be installed using plugins, or you can use the `llm --functions` option to pass tools implemented as PYthon functions directly, as {ref}`described here `. + +(tools-implementation)= + +## LLM's implementation of tools + +In LLM every tool is defined as a Python function. The function can take any number of arguments and can return a string or an object that can be converted to a string. + +Tool functions should include a docstring that describes what the function does. This docstring will become the description that is passed to the model. + +Tools can also be defined as {ref}`toolbox classes `, a subclass of `llm.Toolbox` that allows multiple related tools to be bundled together. Toolbox classes can be configured when they are instantiated, and can also maintain state in between multiple tool calls. + +The Python API can accept functions directly. The command-line interface has two ways for tools to be defined: via plugins that implement the {ref}`register_tools() plugin hook `, or directly on the command-line using the `--functions` argument to specify a block of Python code defining one or more functions - or a path to a Python file containing the same. + +You can use tools {ref}`with the LLM command-line tool ` or {ref}`with the Python API `. + +(tools-default)= + +## Default tools + +LLM includes some default tools for you to try out: + +- `llm_version()` returns the current version of LLM +- `llm_time()` returns the current local and UTC time + +Try them like this: + +```bash +llm -T llm_version -T llm_time 'Give me the current time and LLM version' --td +``` + +(tools-tips)= + +## Tips for implementing tools + +Consult the {ref}`register_tools() plugin hook ` documentation for examples of how to implement tools in plugins. + +If your plugin needs access to API secrets I recommend storing those using `llm keys set api-name` and then reading them using the {ref}`plugin-utilities-get-key` utility function. This avoids secrets being logged to the database as part of tool calls. + +If your tool implementation needs to know which tool call invoked it - for example to key state against the unique `tool_call_id` - add a parameter named `llm_tool_call` to your function. It will be passed the `llm.ToolCall` object for the current invocation, and is hidden from the schema the model sees. See {ref}`python-api-tools-llm-tool-call` for details. + + diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..1b237f7 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,1666 @@ +(usage)= +# Usage + +The command to run a prompt is `llm prompt 'your prompt'`. This is the default command, so you can use `llm 'your prompt'` as a shortcut. + +(usage-executing-prompts)= +## Executing a prompt + +These examples use the default OpenAI `gpt-4o-mini` model, which requires you to first {ref}`set an OpenAI API key `. + +You can {ref}`install LLM plugins ` to use models from other providers, including openly licensed models you can run directly on your own computer. + +To run a prompt, streaming tokens as they come in: +```bash +llm 'Ten names for cheesecakes' +``` +To disable streaming and only return the response once it has completed: +```bash +llm 'Ten names for cheesecakes' --no-stream +``` +To switch from ChatGPT 4o-mini (the default) to GPT-4o: +```bash +llm 'Ten names for cheesecakes' -m gpt-4o +``` +You can use `-m 4o` as an even shorter shortcut. + +Pass `--model ` to use a different model. Run `llm models` to see a list of available models. + +Or if you know the name is too long to type, use `-q` once or more to provide search terms - the model with the shortest model ID that matches all of those terms (as a lowercase substring) will be used: +```bash +llm 'Ten names for cheesecakes' -q 4o -q mini +``` +To change the default model for the current session, set the `LLM_MODEL` environment variable: +```bash +export LLM_MODEL=gpt-4.1-mini +llm 'Ten names for cheesecakes' # Uses gpt-4.1-mini +``` + +You can send a prompt directly to standard input like this: +```bash +echo 'Ten names for cheesecakes' | llm +``` +If you send text to standard input and provide arguments, the resulting prompt will consist of the piped content followed by the arguments: +```bash +cat myscript.py | llm 'explain this code' +``` +Will run a prompt of: +``` + explain this code +``` +For models that support them, {ref}`system prompts ` are a better tool for this kind of prompting. + +(usage-model-options)= +### Model options + +Some models support options. You can pass these using `-o/--option name value` - for example, to set the temperature to 1.5 run this: + +```bash +llm 'Ten names for cheesecakes' -o temperature 1.5 +``` + +Use the `llm models --options` command to see which options are supported by each model, or `llm -m gpt-5.5 --options` to show the options for a specific selected model. + +You can also {ref}`configure default options ` for a model using the `llm models options` commands. + +(usage-attachments)= +### Attachments + +Some models are multi-modal, which means they can accept input in more than just text. GPT-4o and GPT-4o mini can accept images, and models such as Google Gemini 1.5 can accept audio and video as well. + +LLM calls these **attachments**. You can pass attachments using the `-a` option like this: + +```bash +llm "describe this image" -a https://static.simonwillison.net/static/2024/pelicans.jpg +``` +Attachments can be passed using URLs or file paths, and you can attach more than one attachment to a single prompt: +```bash +llm "extract text" -a image1.jpg -a image2.jpg +``` +You can also pipe an attachment to LLM by using `-` as the filename: +```bash +cat image.jpg | llm "describe this image" -a - +``` +LLM will attempt to automatically detect the content type of the image. If this doesn't work you can instead use the `--attachment-type` option (`--at` for short) which takes the URL/path plus an explicit content type: +```bash +cat myfile | llm "describe this image" --at - image/jpeg +``` + +(usage-system-prompts)= +### System prompts + +You can use `-s/--system '...'` to set a system prompt. +```bash +llm 'SQL to calculate total sales by month' \ + --system 'You are an exaggerated sentient cheesecake that knows SQL and talks about cheesecake a lot' +``` +This is useful for piping content to standard input, for example: +```bash +curl -s 'https://simonwillison.net/2023/May/15/per-interpreter-gils/' | \ + llm -s 'Suggest topics for this post as a JSON array' +``` +Or to generate a description of changes made to a Git repository since the last commit: +```bash +git diff | llm -s 'Describe these changes' +``` +Different models support system prompts in different ways. + +The OpenAI models are particularly good at using system prompts as instructions for how they should process additional input sent as part of the regular prompt. + +Other models might use system prompts change the default voice and attitude of the model. + +System prompts can be saved as {ref}`templates ` to create reusable tools. For example, you can create a template called `pytest` like this: + +```bash +llm -s 'write pytest tests for this code' --save pytest +``` +And then use the new template like this: +```bash +cat llm/utils.py | llm -t pytest +``` +See {ref}`prompt templates ` for more. + +(usage-tools)= +### Tools + +Many models support the ability to call {ref}`external tools `. Tools can be provided {ref}`by plugins ` or you can pass a `--functions CODE` option to LLM to define one or more Python functions that the model can then call. + +```bash +llm --functions ' +def multiply(x: int, y: int) -> int: + """Multiply two numbers.""" + return x * y +' 'what is 34234 * 213345' +``` +Add `--td/--tools-debug` to see full details of the tools that are being executed. You can also set the `LLM_TOOLS_DEBUG` environment variable to `1` to enable this for all prompts. +```bash +llm --functions ' +def multiply(x: int, y: int) -> int: + """Multiply two numbers.""" + return x * y +' 'what is 34234 * 213345' --td +``` +Output: +``` +Tool call: multiply({'x': 34234, 'y': 213345}) + 7303652730 +34234 multiplied by 213345 is 7,303,652,730. +``` +Or add `--ta/--tools-approve` to approve each tool call interactively before it is executed: + +```bash +llm --functions ' +def multiply(x: int, y: int) -> int: + """Multiply two numbers.""" + return x * y +' 'what is 34234 * 213345' --ta +``` +Output: +``` +Tool call: multiply({'x': 34234, 'y': 213345}) +Approve tool call? [y/N]: +``` +The `--functions` option can be passed more than once, and can also point to the filename of a `.py` file containing one or more functions. + +If you have any tools that have been made available via plugins you can add them to the prompt using `--tool/-T` option. For example, using [llm-tools-simpleeval](https://github.com/simonw/llm-tools-simpleeval) like this: + +```bash +llm install llm-tools-simpleeval +llm --tool simple_eval "4444 * 233423" --td +``` +Run this command to see a list of available tools from plugins: +```bash +llm tools +``` +If you run a prompt that uses tools from plugins (as opposed to tools provided using the `--functions` option) continuing that conversation using `llm -c` will reuse the tools from the first prompt. Running `llm chat -c` will start a chat that continues using those same tools. For example: + +``` +llm -T simple_eval "12345 * 12345" --td +Tool call: simple_eval({'expression': '12345 * 12345'}) + 152399025 +12345 multiplied by 12345 equals 152,399,025. +llm -c "that * 6" --td +Tool call: simple_eval({'expression': '152399025 * 6'}) + 914394150 +152,399,025 multiplied by 6 equals 914,394,150. +llm chat -c --td +Chatting with gpt-4.1-mini +Type 'exit' or 'quit' to exit +Type '!multi' to enter multiple lines, then '!end' to finish +Type '!edit' to open your default editor and modify the prompt +> / 123 +Tool call: simple_eval({'expression': '914394150 / 123'}) + 7434098.780487805 +914,394,150 divided by 123 is approximately 7,434,098.78. +``` +Some tools are bundled in a configurable collection of tools called a **toolbox**. This means a single `--tool` option can load multiple related tools. + +[llm-tools-datasette](https://github.com/simonw/llm-tools-datasette) is one example. Using a toolbox looks like this: + +```bash +llm install llm-tools-datasette +llm -T 'Datasette("https://datasette.io/content")' "Show tables" --td +``` +Toolboxes always start with a capital letter. They can be configured by passing a tool specification, which should fit the following patterns: + +- Empty: `ToolboxName` or `ToolboxName()` - has no configuration arguments +- JSON object: `ToolboxName({"key": "value", "other": 42})` +- Single JSON value: `ToolboxName("hello")` or `ToolboxName([1,2,3])` +- Key-value pairs: `ToolboxName(name="test", count=5, items=[1,2])` - treated the same as `{"name": "test", "count": 5, "items": [1, 2]}`, all values must be valid JSON + +Toolboxes are not currently supported with the `llm -c` option, but they work well with `llm chat`. Try chatting with the Datasette content database like this: + +```bash +llm chat -T 'Datasette("https://datasette.io/content")' --td +``` +``` +Chatting with gpt-4.1-mini +Type 'exit' or 'quit' to exit +... +> show tables +``` + +(usage-extract-fenced-code)= +### Extracting fenced code blocks + +If you are using an LLM to generate code it can be useful to retrieve just the code it produces without any of the surrounding explanatory text. + +The `-x/--extract` option will scan the response for the first instance of a Markdown fenced code block - something that looks like this: + +```` +```python +def my_function(): + # ... +``` +```` +It will extract and returns just the content of that block, excluding the fenced coded delimiters. If there are no fenced code blocks it will return the full response. + +Use `--xl/--extract-last` to return the last fenced code block instead of the first. + +The entire response including explanatory text is still logged to the database, and can be viewed using `llm logs -c`. + +(usage-schemas)= +### Schemas + +Some models include the ability to return JSON that matches a provided [JSON schema](https://json-schema.org/). Models from OpenAI, Anthropic and Google Gemini all include this capability. + +Take a look at the {ref}`schemas documentation ` for a detailed guide to using this feature. + +You can pass JSON schemas directly to the `--schema` option: + +```bash +llm --schema '{ + "type": "object", + "properties": { + "dogs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "bio": { + "type": "string" + } + } + } + } + } +}' -m gpt-4o-mini 'invent two dogs' +``` + +Or use LLM's custom {ref}`concise schema syntax ` like this: +```bash +llm --schema 'name,bio' 'invent a dog' +``` +Two use the same concise schema for multiple items use `--schema-multi`: +```bash +llm --schema-multi 'name,bio' 'invent two dogs' +``` +You can also save the JSON schema to a file and reference the filename using `--schema`: + +```bash +llm --schema dogs.schema.json 'invent two dogs' +``` + +Or save your schema {ref}`to a template ` like this: + +```bash +llm --schema dogs.schema.json --save dogs +# Then to use it: +llm -t dogs 'invent two dogs' +``` + +Be warned that different models may support different dialects of the JSON schema specification. + +See {ref}`schemas-logs` for tips on using the `llm logs --schema X` command to access JSON objects you have previously logged using this option. + +(usage-fragments)= +### Fragments + +You can use the `-f/--fragment` option to reference fragments of context that you would like to load into your prompt. Fragments can be specified as URLs, file paths or as aliases to previously saved fragments. + +Fragments are designed for running longer prompts. LLM {ref}`stores prompts in a database `, and the same prompt repeated many times can end up stored as multiple copies, wasting disk space. A fragment will be stored just once and referenced by all of the prompts that use it. + +The `-f` option can accept a path to a file on disk, a URL or the hash or alias of a previous fragment. + +For example, to ask a question about the `robots.txt` file on `llm.datasette.io`: +```bash +llm -f https://llm.datasette.io/robots.txt 'explain this' +``` +For a poem inspired by some Python code on disk: +```bash +llm -f cli.py 'a short snappy poem inspired by this code' +``` +You can use as many `-f` options as you like - the fragments will be concatenated together in the order you provided, with any additional prompt added at the end. + +Fragments can also be used for the system prompt using the `--sf/--system-fragment` option. If you have a file called `explain_code.txt` containing this: + +``` +Explain this code in detail. Include copies of the code quoted in the explanation. +``` +You can run it as the system prompt like this: +```bash +llm -f cli.py --sf explain_code.txt +``` + +You can use the `llm fragments set` command to load a fragment and give it an alias for use in future queries: +```bash +llm fragments set cli cli.py +# Then +llm -f cli 'explain this code' +``` +Use `llm fragments` to list all fragments that have been stored: +```bash +llm fragments +``` +You can search by passing one or more `-q X` search strings. This will return results matching all of those strings, across the source, hash, aliases and content: +```bash +llm fragments -q pytest -q asyncio +``` + +The `llm fragments remove` command removes an alias. It does not delete the fragment record itself as those are linked to previous prompts and responses and cannot be deleted independently of them. +```bash +llm fragments remove cli +``` + +(usage-conversation)= +### Continuing a conversation + +By default, the tool will start a new conversation each time you run it. + +You can opt to continue the previous conversation by passing the `-c/--continue` option: +```bash +llm 'More names' -c +``` +This will re-send the prompts and responses for the previous conversation as part of the call to the language model. Note that this can add up quickly in terms of tokens, especially if you are using expensive models. + +`--continue` will automatically use the same model as the conversation that you are continuing, even if you omit the `-m/--model` option. + +To continue a conversation that is not the most recent one, use the `--cid/--conversation ` option: +```bash +llm 'More names' --cid 01h53zma5txeby33t1kbe3xk8q +``` +You can find these conversation IDs using the `llm logs` command. + +### Tips for using LLM with Bash or Zsh + +To learn more about your computer's operating system based on the output of `uname -a`, run this: +```bash +llm "Tell me about my operating system: $(uname -a)" +``` +This pattern of using `$(command)` inside a double quoted string is a useful way to quickly assemble prompts. + +(usage-completion-prompts)= +### Completion prompts + +Some models are completion models - rather than being tuned to respond to chat style prompts, they are designed to complete a sentence or paragraph. + +An example of this is the `gpt-3.5-turbo-instruct` OpenAI model. + +You can prompt that model the same way as the chat models, but be aware that the prompt format that works best is likely to differ. + +```bash +llm -m gpt-3.5-turbo-instruct 'Reasons to tame a wild beaver:' +``` + +(usage-chat)= + +## Starting an interactive chat + +The `llm chat` command starts an ongoing interactive chat with a model. + +This is particularly useful for models that run on your own machine, since it saves them from having to be loaded into memory each time a new prompt is added to a conversation. + +Run `llm chat`, optionally with a `-m model_id`, to start a chat conversation: + +```bash +llm chat -m chatgpt +``` +Each chat starts a new conversation. A record of each conversation can be accessed through {ref}`the logs `. + +You can pass `-c` to start a conversation as a continuation of your most recent prompt. This will automatically use the most recently used model: + +```bash +llm chat -c +``` + +For models that support them, you can pass options using `-o/--option`: +```bash +llm chat -m gpt-4 -o temperature 0.5 +``` + +You can pass a system prompt to be used for your chat conversation: + +```bash +llm chat -m gpt-4 -s 'You are a sentient cheesecake' +``` +You can also pass {ref}`a template ` - useful for creating chat personas that you wish to return to. + +Here's how to create a template for your GPT-4 powered cheesecake: +```bash +llm --system 'You are a sentient cheesecake' -m gpt-4 --save cheesecake +``` +Now you can start a new chat with your cheesecake any time you like using this: +```bash +llm chat -t cheesecake +``` +``` +Chatting with gpt-4 +Type 'exit' or 'quit' to exit +Type '!multi' to enter multiple lines, then '!end' to finish +Type '!edit' to open your default editor and modify the prompt +Type '!fragment [ ...]' to insert one or more fragments +> who are you? +I am a sentient cheesecake, meaning I am an artificial +intelligence embodied in a dessert form, specifically a +cheesecake. However, I don't consume or prepare foods +like humans do, I communicate, learn and help answer +your queries. +``` + +Type `quit` or `exit` followed by `` to end a chat session. + +Sometimes you may want to paste multiple lines of text into a chat at once - for example when debugging an error message. + +To do that, type `!multi` to start a multi-line input. Type or paste your text, then type `!end` and hit `` to finish. + +If your pasted text might itself contain a `!end` line, you can set a custom delimiter using `!multi abc` followed by `!end abc` at the end: + +``` +Chatting with gpt-4 +Type 'exit' or 'quit' to exit +Type '!multi' to enter multiple lines, then '!end' to finish +Type '!edit' to open your default editor and modify the prompt. +Type '!fragment [ ...]' to insert one or more fragments +> !multi custom-end + Explain this error: + + File "/opt/homebrew/Caskroom/miniconda/base/lib/python3.10/urllib/request.py", line 1391, in https_open + return self.do_open(http.client.HTTPSConnection, req, + File "/opt/homebrew/Caskroom/miniconda/base/lib/python3.10/urllib/request.py", line 1351, in do_open + raise URLError(err) +urllib.error.URLError: + + !end custom-end +``` + +You can also use `!edit` to open your default editor and modify the prompt before sending it to the model. + +``` +Chatting with gpt-4 +Type 'exit' or 'quit' to exit +Type '!multi' to enter multiple lines, then '!end' to finish +Type '!edit' to open your default editor and modify the prompt. +Type '!fragment [ ...]' to insert one or more fragments +> !edit +``` + +`llm chat` takes the same `--tool/-T` and `--functions` options as `llm prompt`. You can use this to start a chat with the specified {ref}`tools ` enabled. + +## Listing available models + +The `llm models` command lists every model that can be used with LLM, along with their aliases. This includes models that have been installed using {ref}`plugins `. + +```bash +llm models +``` +Example output: +``` +OpenAI Chat: gpt-4o (aliases: 4o) +OpenAI Chat: gpt-4o-mini (aliases: 4o-mini) +OpenAI Chat: o1-preview +OpenAI Chat: o1-mini +GeminiPro: gemini-1.5-pro-002 +GeminiPro: gemini-1.5-flash-002 +... +``` + +Add one or more `-q term` options to search for models matching all of those search terms: +```bash +llm models -q gpt-4o +llm models -q 4o -q mini +``` +Use one or more `-m` options to indicate specific models, either by their model ID or one of their aliases: +```bash +llm models -m gpt-4o -m gemini-1.5-pro-002 +``` +Add `--options` to also see documentation for the options supported by each model: +```bash +llm models --options +``` +Output: + +``` +OpenAI Chat: gpt-4o (aliases: 4o) + Options: + temperature: float + What sampling temperature to use, between 0 and 2. Higher values like + 0.8 will make the output more random, while lower values like 0.2 will + make it more focused and deterministic. + max_tokens: int + Maximum number of tokens to generate. + top_p: float + An alternative to sampling with temperature, called nucleus sampling, + where the model considers the results of the tokens with top_p + probability mass. So 0.1 means only the tokens comprising the top 10% + probability mass are considered. Recommended to use top_p or + temperature but not both. + frequency_penalty: float + Number between -2.0 and 2.0. Positive values penalize new tokens based + on their existing frequency in the text so far, decreasing the model's + likelihood to repeat the same line verbatim. + presence_penalty: float + Number between -2.0 and 2.0. Positive values penalize new tokens based + on whether they appear in the text so far, increasing the model's + likelihood to talk about new topics. + stop: str + A string where the API will stop generating further tokens. + logit_bias: dict, str + Modify the likelihood of specified tokens appearing in the completion. + Pass a JSON string like '{"1712":-100, "892":-100, "1489":-100}' + seed: int + Integer seed to attempt to sample deterministically + json_object: boolean + Output a valid JSON object {...}. Prompt must mention JSON. + image_detail: str + Controls the detail level for image attachments. Supported values are + low, high, and auto. + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: chatgpt-4o-latest (aliases: chatgpt-4o) + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4o-mini (aliases: 4o-mini) + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4o-audio-preview + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Attachment types: + audio/mpeg, audio/wav + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4o-audio-preview-2024-12-17 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Attachment types: + audio/mpeg, audio/wav + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4o-audio-preview-2024-10-01 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Attachment types: + audio/mpeg, audio/wav + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4o-mini-audio-preview + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Attachment types: + audio/mpeg, audio/wav + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4o-mini-audio-preview-2024-12-17 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Attachment types: + audio/mpeg, audio/wav + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4.1 (aliases: 4.1) + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4.1-mini (aliases: 4.1-mini) + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4.1-nano (aliases: 4.1-nano) + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-3.5-turbo (aliases: 3.5, chatgpt) + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-3.5-turbo-16k (aliases: chatgpt-16k, 3.5-16k) + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4 (aliases: 4, gpt4) + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4-32k (aliases: 4-32k) + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4-1106-preview + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4-0125-preview + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4-turbo-2024-04-09 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4-turbo (aliases: gpt-4-turbo-preview, 4-turbo, 4t) + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4.5-preview-2025-02-27 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: gpt-4.5-preview (aliases: gpt-4.5) + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: o1 + Options: + temperature: float + What sampling temperature to use, between 0 and 2. Higher values like + 0.8 will make the output more random, while lower values like 0.2 will + make it more focused and deterministic. + max_tokens: int + Maximum number of tokens to generate. + top_p: float + An alternative to sampling with temperature, called nucleus sampling, + where the model considers the results of the tokens with top_p + probability mass. So 0.1 means only the tokens comprising the top 10% + probability mass are considered. Recommended to use top_p or + temperature but not both. + frequency_penalty: float + Number between -2.0 and 2.0. Positive values penalize new tokens based + on their existing frequency in the text so far, decreasing the model's + likelihood to repeat the same line verbatim. + presence_penalty: float + Number between -2.0 and 2.0. Positive values penalize new tokens based + on whether they appear in the text so far, increasing the model's + likelihood to talk about new topics. + stop: str + A string where the API will stop generating further tokens. + logit_bias: dict, str + Modify the likelihood of specified tokens appearing in the completion. + Pass a JSON string like '{"1712":-100, "892":-100, "1489":-100}' + seed: int + Integer seed to attempt to sample deterministically + json_object: boolean + Output a valid JSON object {...}. Prompt must mention JSON. + chat_completions: boolean + Force the use of the older /v1/chat/completions endpoint instead of + /v1/responses. Most callers should leave this off; set to true to fall + back to the Chat Completions code path for compatibility. + image_detail: str + Controls the detail level for image attachments. Supported values are + low, high, and auto. + reasoning_effort: str + Constraints effort on reasoning for reasoning models. Currently + supported values are low, medium, and high. Reducing reasoning effort + can result in faster responses and fewer tokens used on reasoning in a + response. + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: o1-2024-12-17 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: o1-preview + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Chat: o1-mini + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + image_detail: str + Features: + - streaming + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: o3-mini + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: o3 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: o4-mini + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5-mini + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5-nano + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5-2025-08-07 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5-mini-2025-08-07 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5-nano-2025-08-07 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5.1 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5.1-chat-latest + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5.2 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5.2-chat-latest + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5.4 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5.4-2026-03-05 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5.4-mini + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5.4-mini-2026-03-17 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5.4-nano + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5.4-nano-2026-03-17 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5.5 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Responses: gpt-5.5-2026-04-23 + Options: + temperature: float + max_tokens: int + top_p: float + frequency_penalty: float + presence_penalty: float + stop: str + logit_bias: dict, str + seed: int + json_object: boolean + chat_completions: boolean + image_detail: str + reasoning_effort: str + verbosity: str + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Features: + - streaming + - schemas + - tools + - async + Keys: + key: openai + env_var: OPENAI_API_KEY +OpenAI Completion: gpt-3.5-turbo-instruct (aliases: 3.5-instruct, chatgpt-instruct) + Options: + temperature: float + What sampling temperature to use, between 0 and 2. Higher values like + 0.8 will make the output more random, while lower values like 0.2 will + make it more focused and deterministic. + max_tokens: int + Maximum number of tokens to generate. + top_p: float + An alternative to sampling with temperature, called nucleus sampling, + where the model considers the results of the tokens with top_p + probability mass. So 0.1 means only the tokens comprising the top 10% + probability mass are considered. Recommended to use top_p or + temperature but not both. + frequency_penalty: float + Number between -2.0 and 2.0. Positive values penalize new tokens based + on their existing frequency in the text so far, decreasing the model's + likelihood to repeat the same line verbatim. + presence_penalty: float + Number between -2.0 and 2.0. Positive values penalize new tokens based + on whether they appear in the text so far, increasing the model's + likelihood to talk about new topics. + stop: str + A string where the API will stop generating further tokens. + logit_bias: dict, str + Modify the likelihood of specified tokens appearing in the completion. + Pass a JSON string like '{"1712":-100, "892":-100, "1489":-100}' + seed: int + Integer seed to attempt to sample deterministically + logprobs: int + Include the log probabilities of most likely N per token + Features: + - streaming + Keys: + key: openai + env_var: OPENAI_API_KEY + +``` + + +When running a prompt you can pass the full model name or any of the aliases to the `-m/--model` option: +```bash +llm -m 4o \ + 'As many names for cheesecakes as you can think of, with detailed descriptions' +``` + +(usage-executing-default-options)= + +## Setting default options for models + +To configure a default option for a specific model, use the `llm models options set` command: +```bash +llm models options set gpt-4o temperature 0.5 +``` +This option will then be applied automatically any time you run a prompt through the `gpt-4o` model. + +Default options are stored in the `model_options.json` file in the LLM configuration directory. + +You can list all default options across all models using the `llm models options list` command: +```bash +llm models options list +``` +Or show them for an individual model with `llm models options show `: +```bash +llm models options show gpt-4o +``` +To clear a default option, use the `llm models options clear` command: +```bash +llm models options clear gpt-4o temperature +``` +Or clear all default options for a model like this: +```bash +llm models options clear gpt-4o +``` +Default model options are respected by both the `llm prompt` and the `llm chat` commands. They will not be applied when you use LLM as a {ref}`Python library `. diff --git a/llm/__init__.py b/llm/__init__.py new file mode 100644 index 0000000..52d3564 --- /dev/null +++ b/llm/__init__.py @@ -0,0 +1,503 @@ +from .hookspecs import hookimpl +from .errors import ( + ModelError, + NeedsKeyException, +) +from .models import ( + AsyncConversation, + AsyncKeyModel, + AsyncModel, + AsyncResponse, + Attachment, + CancelToolCall, + PauseChain, + Conversation, + EmbeddingModel, + EmbeddingModelWithAliases, + KeyModel, + Model, + ModelWithAliases, + Options, + Prompt, + Response, + Tool, + Toolbox, + ToolCall, + ToolOutput, + ToolResult, + Usage, +) +from .parts import ( + Message, + assistant, + system, + tool_message, + user, +) +from .utils import schema_dsl, Fragment +from .embeddings import Collection +from .templates import Template +from .plugins import pm, load_plugins +import click +from typing import Any, Dict, List, Optional, Callable, Type, Union +import inspect +import json +import os +import pathlib +import struct + +__all__ = [ + "AsyncConversation", + "AsyncKeyModel", + "AsyncModel", + "AsyncResponse", + "assistant", + "Attachment", + "CancelToolCall", + "Collection", + "Conversation", + "Fragment", + "get_async_model", + "get_key", + "get_model", + "hookimpl", + "KeyModel", + "Message", + "Model", + "ModelError", + "NeedsKeyException", + "Options", + "PauseChain", + "Prompt", + "Response", + "schema_dsl", + "system", + "Template", + "Tool", + "Toolbox", + "ToolCall", + "tool_message", + "ToolOutput", + "ToolResult", + "Usage", + "user", + "user_dir", +] +DEFAULT_MODEL = "gpt-4o-mini" + + +def get_plugins(all=False): + plugins = [] + plugin_to_distinfo = dict(pm.list_plugin_distinfo()) + for plugin in pm.get_plugins(): + if not all and plugin.__name__.startswith("llm.default_plugins."): + continue + plugin_info = { + "name": plugin.__name__, + "hooks": [h.name for h in pm.get_hookcallers(plugin)], + } + distinfo = plugin_to_distinfo.get(plugin) + if distinfo: + plugin_info["version"] = distinfo.version + plugin_info["name"] = ( + getattr(distinfo, "name", None) or distinfo.project_name + ) + plugins.append(plugin_info) + return plugins + + +def get_models_with_aliases() -> List["ModelWithAliases"]: + model_aliases = [] + + # Include aliases from aliases.json + aliases_path = user_dir() / "aliases.json" + extra_model_aliases: Dict[str, list] = {} + if aliases_path.exists(): + configured_aliases = json.loads(aliases_path.read_text()) + for alias, model_id in configured_aliases.items(): + extra_model_aliases.setdefault(model_id, []).append(alias) + + def register(model, async_model=None, aliases=None): + alias_list = list(aliases or []) + if model.model_id in extra_model_aliases: + alias_list.extend(extra_model_aliases[model.model_id]) + model_aliases.append(ModelWithAliases(model, async_model, alias_list)) + + load_plugins() + pm.hook.register_models(register=register, model_aliases=model_aliases) + + return model_aliases + + +def _get_loaders(hook_method) -> Dict[str, Callable]: + load_plugins() + loaders = {} + + def register(prefix, loader): + suffix = 0 + prefix_to_try = prefix + while prefix_to_try in loaders: + suffix += 1 + prefix_to_try = f"{prefix}_{suffix}" + loaders[prefix_to_try] = loader + + hook_method(register=register) + return loaders + + +def get_template_loaders() -> Dict[str, Callable[[str], Template]]: + """Get template loaders registered by plugins.""" + return _get_loaders(pm.hook.register_template_loaders) + + +def get_fragment_loaders() -> Dict[ + str, + Callable[[str], Union[Fragment, Attachment, List[Union[Fragment, Attachment]]]], +]: + """Get fragment loaders registered by plugins.""" + return _get_loaders(pm.hook.register_fragment_loaders) + + +def get_tools() -> Dict[str, Union[Tool, Type[Toolbox]]]: + """Return all tools (llm.Tool and llm.Toolbox) registered by plugins.""" + load_plugins() + tools: Dict[str, Union[Tool, Type[Toolbox]]] = {} + + # Variable to track current plugin name + current_plugin_name = None + + def register( + tool_or_function: Union[Tool, Type[Toolbox], Callable[..., Any]], + name: Optional[str] = None, + ) -> None: + tool: Union[Tool, Type[Toolbox], None] = None + + # If it's a Toolbox class, set the plugin field on it + if inspect.isclass(tool_or_function): + if issubclass(tool_or_function, Toolbox): + tool = tool_or_function + if current_plugin_name: + tool.plugin = current_plugin_name + tool.name = name or tool.__name__ + else: + raise TypeError( + "Toolbox classes must inherit from llm.Toolbox, {} does not.".format( + tool_or_function.__name__ + ) + ) + + # If it's already a Tool instance, use it directly + elif isinstance(tool_or_function, Tool): + tool = tool_or_function + if name: + tool.name = name + if current_plugin_name: + tool.plugin = current_plugin_name + + # If it's a bare function, wrap it in a Tool + else: + tool = Tool.function(tool_or_function, name=name) + if current_plugin_name: + tool.plugin = current_plugin_name + + # Get the name for the tool/toolbox + if tool: + # For Toolbox classes, use their name attribute or class name + if inspect.isclass(tool) and issubclass(tool, Toolbox): + prefix = name or getattr(tool, "name", tool.__name__) or "" + else: + prefix = name or tool.name or "" + + suffix = 0 + candidate = prefix + + # Avoid name collisions + while candidate in tools: + suffix += 1 + candidate = f"{prefix}_{suffix}" + + tools[candidate] = tool + + # Call each plugin's register_tools hook individually to track current_plugin_name + for plugin in pm.get_plugins(): + current_plugin_name = pm.get_name(plugin) + hook_caller = pm.hook.register_tools + plugin_impls = [ + impl for impl in hook_caller.get_hookimpls() if impl.plugin is plugin + ] + for impl in plugin_impls: + impl.function(register=register) + + return tools + + +def get_embedding_models_with_aliases() -> List["EmbeddingModelWithAliases"]: + model_aliases = [] + + # Include aliases from aliases.json + aliases_path = user_dir() / "aliases.json" + extra_model_aliases: Dict[str, list] = {} + if aliases_path.exists(): + configured_aliases = json.loads(aliases_path.read_text()) + for alias, model_id in configured_aliases.items(): + extra_model_aliases.setdefault(model_id, []).append(alias) + + def register(model, aliases=None): + alias_list = list(aliases or []) + if model.model_id in extra_model_aliases: + alias_list.extend(extra_model_aliases[model.model_id]) + model_aliases.append(EmbeddingModelWithAliases(model, alias_list)) + + load_plugins() + pm.hook.register_embedding_models(register=register) + + return model_aliases + + +def get_embedding_models(): + models = [] + + def register(model, aliases=None): + models.append(model) + + load_plugins() + pm.hook.register_embedding_models(register=register) + return models + + +def get_embedding_model(name): + aliases = get_embedding_model_aliases() + try: + return aliases[name] + except KeyError: + raise UnknownModelError("Unknown model: " + str(name)) + + +def get_embedding_model_aliases() -> Dict[str, EmbeddingModel]: + model_aliases = {} + for model_with_aliases in get_embedding_models_with_aliases(): + for alias in model_with_aliases.aliases: + model_aliases[alias] = model_with_aliases.model + model_aliases[model_with_aliases.model.model_id] = model_with_aliases.model + return model_aliases + + +def get_async_model_aliases() -> Dict[str, AsyncModel]: + async_model_aliases = {} + for model_with_aliases in get_models_with_aliases(): + if model_with_aliases.async_model: + for alias in model_with_aliases.aliases: + async_model_aliases[alias] = model_with_aliases.async_model + async_model_aliases[model_with_aliases.model.model_id] = ( + model_with_aliases.async_model + ) + return async_model_aliases + + +def get_model_aliases() -> Dict[str, Model]: + model_aliases = {} + for model_with_aliases in get_models_with_aliases(): + if model_with_aliases.model: + for alias in model_with_aliases.aliases: + model_aliases[alias] = model_with_aliases.model + model_aliases[model_with_aliases.model.model_id] = model_with_aliases.model + return model_aliases + + +class UnknownModelError(KeyError): + pass + + +def get_models() -> List[Model]: + "Get all registered models" + models_with_aliases = get_models_with_aliases() + return [mwa.model for mwa in models_with_aliases if mwa.model] + + +def get_async_models() -> List[AsyncModel]: + "Get all registered async models" + models_with_aliases = get_models_with_aliases() + return [mwa.async_model for mwa in models_with_aliases if mwa.async_model] + + +def get_async_model(name: Optional[str] = None) -> AsyncModel: + "Get an async model by name or alias" + aliases = get_async_model_aliases() + name = name or get_default_model() + try: + return aliases[name] + except KeyError: + # Does a sync model exist? + sync_model = None + try: + sync_model = get_model(name, _skip_async=True) + except UnknownModelError: + pass + if sync_model: + raise UnknownModelError("Unknown async model (sync model exists): " + name) + else: + raise UnknownModelError("Unknown model: " + name) + + +def get_model(name: Optional[str] = None, _skip_async: bool = False) -> Model: + "Get a model by name or alias" + aliases = get_model_aliases() + name = name or get_default_model() + try: + return aliases[name] + except KeyError: + # Does an async model exist? + if _skip_async: + raise UnknownModelError("Unknown model: " + name) + async_model = None + try: + async_model = get_async_model(name) + except UnknownModelError: + pass + if async_model: + raise UnknownModelError("Unknown model (async model exists): " + name) + else: + raise UnknownModelError("Unknown model: " + name) + + +def get_key( + explicit_key: Optional[str] = None, + key_alias: Optional[str] = None, + env_var: Optional[str] = None, + *, + alias: Optional[str] = None, + env: Optional[str] = None, + input: Optional[str] = None, +) -> Optional[str]: + """ + Return an API key based on a hierarchy of potential sources. You should use the keyword arguments, + the positional arguments are here purely for backwards-compatibility with older code. + + :param input: Input provided by the user. This may be the key, or an alias of a key in keys.json. + :param alias: The alias used to retrieve the key from the keys.json file. + :param env: Name of the environment variable to check for the key as a final fallback. + """ + if alias: + key_alias = alias + if env: + env_var = env + if input: + explicit_key = input + stored_keys = load_keys() + # If user specified an alias, use the key stored for that alias + if explicit_key in stored_keys: + return stored_keys[explicit_key] + if explicit_key: + # User specified a key that's not an alias, use that + return explicit_key + # Stored key over-rides environment variables over-ride the default key + if key_alias in stored_keys: + return stored_keys[key_alias] + # Finally try environment variable + if env_var and os.environ.get(env_var): + return os.environ[env_var] + # Couldn't find it + return None + + +def load_keys(): + path = user_dir() / "keys.json" + if path.exists(): + return json.loads(path.read_text()) + else: + return {} + + +def user_dir(): + llm_user_path = os.environ.get("LLM_USER_PATH") + if llm_user_path: + path = pathlib.Path(llm_user_path) + else: + path = pathlib.Path(click.get_app_dir("io.datasette.llm")) + path.mkdir(exist_ok=True, parents=True) + return path + + +def set_alias(alias, model_id_or_alias): + """ + Set an alias to point to the specified model. + """ + path = user_dir() / "aliases.json" + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + path.write_text("{}\n") + try: + current = json.loads(path.read_text()) + except json.decoder.JSONDecodeError: + # We're going to write a valid JSON file in a moment: + current = {} + # Resolve model_id_or_alias to a model_id + try: + model = get_model(model_id_or_alias) + model_id = model.model_id + except UnknownModelError: + # Try to resolve it to an embedding model + try: + model = get_embedding_model(model_id_or_alias) + model_id = model.model_id + except UnknownModelError: + # Set the alias to the exact string they provided instead + model_id = model_id_or_alias + current[alias] = model_id + path.write_text(json.dumps(current, indent=4) + "\n") + + +def remove_alias(alias): + """ + Remove an alias. + """ + path = user_dir() / "aliases.json" + if not path.exists(): + raise KeyError("No aliases.json file exists") + try: + current = json.loads(path.read_text()) + except json.decoder.JSONDecodeError: + raise KeyError("aliases.json file is not valid JSON") + if alias not in current: + raise KeyError("No such alias: {}".format(alias)) + del current[alias] + path.write_text(json.dumps(current, indent=4) + "\n") + + +def encode(values): + return struct.pack("<" + "f" * len(values), *values) + + +def decode(binary): + return struct.unpack("<" + "f" * (len(binary) // 4), binary) + + +def cosine_similarity(a, b): + dot_product = sum(x * y for x, y in zip(a, b)) + magnitude_a = sum(x * x for x in a) ** 0.5 + magnitude_b = sum(x * x for x in b) ** 0.5 + return dot_product / (magnitude_a * magnitude_b) + + +def get_default_model(filename="default_model.txt", default=DEFAULT_MODEL): + path = user_dir() / filename + if path.exists(): + return path.read_text().strip() + else: + return default + + +def set_default_model(model, filename="default_model.txt"): + path = user_dir() / filename + if model is None and path.exists(): + path.unlink() + else: + path.write_text(model) + + +def get_default_embedding_model(): + return get_default_model("default_embedding_model.txt", None) + + +def set_default_embedding_model(model): + set_default_model(model, "default_embedding_model.txt") diff --git a/llm/__main__.py b/llm/__main__.py new file mode 100644 index 0000000..98dcca0 --- /dev/null +++ b/llm/__main__.py @@ -0,0 +1,4 @@ +from .cli import cli + +if __name__ == "__main__": + cli() diff --git a/llm/cli.py b/llm/cli.py new file mode 100644 index 0000000..19e462c --- /dev/null +++ b/llm/cli.py @@ -0,0 +1,4214 @@ +import asyncio +import click +from click_default_group import DefaultGroup +from dataclasses import asdict +from importlib.metadata import version +import io +import json +import os +from llm import ( + Attachment, + AsyncConversation, + AsyncKeyModel, + AsyncResponse, + CancelToolCall, + Collection, + Conversation, + Fragment, + Response, + Template, + Tool, + Toolbox, + UnknownModelError, + KeyModel, + encode, + get_async_model, + get_default_model, + get_default_embedding_model, + get_embedding_models_with_aliases, + get_embedding_model_aliases, + get_embedding_model, + get_plugins, + get_tools, + get_fragment_loaders, + get_template_loaders, + get_model, + get_model_aliases, + get_models_with_aliases, + user_dir, + set_alias, + set_default_model, + set_default_embedding_model, + remove_alias, +) +from llm.models import _BaseConversation, ChainResponse + +from .migrations import migrate +from .plugins import pm, load_plugins +from .utils import ( + ensure_fragment, + extract_fenced_code_block, + find_unused_key, + has_plugin_prefix, + instantiate_from_spec, + make_schema_id, + maybe_fenced_code, + mimetype_from_path, + mimetype_from_string, + multi_schema, + output_rows_as_json, + resolve_schema_input, + schema_dsl, + schema_summary, + token_usage_string, + truncate_string, +) +import base64 +import httpx +import inspect +import pathlib +import pydantic +import re +import readline +from runpy import run_module +import shutil +import sqlite_utils +from sqlite_utils.utils import rows_from_file, Format +import sys +import textwrap +from typing import cast, Dict, Optional, Iterable, List, Union, Tuple, Type, Any +import warnings +import yaml + +warnings.simplefilter("ignore", ResourceWarning) + +DEFAULT_TEMPLATE = "prompt: " + + +class FragmentNotFound(Exception): + pass + + +def display_stream_events(events, *, show_reasoning=True): + """Consume a sync iterator of StreamEvents and write them. + + Text events go to stdout. Reasoning events go to stderr in dim style. + A newline is written to stderr at each reasoning→text transition so + the assistant text starts on a fresh visual line. + """ + was_reasoning = False + for event in events: + if event.type == "text": + if was_reasoning and show_reasoning: + click.echo("", err=True) + was_reasoning = False + click.echo(event.chunk, nl=False) + elif event.type == "reasoning" and show_reasoning: + was_reasoning = True + click.echo(click.style(event.chunk, dim=True), nl=False, err=True) + + +async def display_async_stream_events(events, *, show_reasoning=True): + """Async counterpart of display_stream_events.""" + was_reasoning = False + async for event in events: + if event.type == "text": + if was_reasoning and show_reasoning: + click.echo("", err=True) + was_reasoning = False + click.echo(event.chunk, nl=False) + elif event.type == "reasoning" and show_reasoning: + was_reasoning = True + click.echo(click.style(event.chunk, dim=True), nl=False, err=True) + + +def validate_fragment_alias(ctx, param, value): + if not re.match(r"^[a-zA-Z0-9_-]+$", value): + raise click.BadParameter("Fragment alias must be alphanumeric") + return value + + +def resolve_fragments( + db: sqlite_utils.Database, fragments: Iterable[str], allow_attachments: bool = False +) -> List[Union[Fragment, Attachment]]: + """ + Resolve fragment strings into a mixed of llm.Fragment() and llm.Attachment() objects. + """ + + def _load_by_alias(fragment: str) -> Tuple[Optional[str], Optional[str]]: + rows = list( + db.query( + """ + select content, source from fragments + left join fragment_aliases on fragments.id = fragment_aliases.fragment_id + where alias = :alias or hash = :alias limit 1 + """, + {"alias": fragment}, + ) + ) + if rows: + row = rows[0] + return row["content"], row["source"] + return None, None + + # The fragment strings could be URLs or paths or plugin references + resolved: List[Union[Fragment, Attachment]] = [] + for fragment in fragments: + if fragment.startswith("http://") or fragment.startswith("https://"): + llm_version = version("llm") + headers = {"User-Agent": f"llm/{llm_version} (https://llm.datasette.io/)"} + client = httpx.Client( + follow_redirects=True, max_redirects=3, headers=headers + ) + response = client.get(fragment) + response.raise_for_status() + resolved.append(Fragment(response.text, fragment)) + elif fragment == "-": + resolved.append(Fragment(sys.stdin.read(), "-")) + elif has_plugin_prefix(fragment): + prefix, rest = fragment.split(":", 1) + loaders = get_fragment_loaders() + if prefix not in loaders: + raise FragmentNotFound("Unknown fragment prefix: {}".format(prefix)) + loader = loaders[prefix] + try: + result = loader(rest) + if not isinstance(result, list): + result = [result] + if not allow_attachments and any( + isinstance(r, Attachment) for r in result + ): + raise FragmentNotFound( + "Fragment loader {} returned a disallowed attachment".format( + prefix + ) + ) + resolved.extend(result) + except Exception as ex: + raise FragmentNotFound( + "Could not load fragment {}: {}".format(fragment, ex) + ) + else: + # Try from the DB + content, source = _load_by_alias(fragment) + if content is not None: + resolved.append(Fragment(content, source)) + else: + # Now try path + path = pathlib.Path(fragment) + if path.exists(): + resolved.append(Fragment(path.read_text(), str(path.resolve()))) + else: + raise FragmentNotFound(f"Fragment '{fragment}' not found") + return resolved + + +def process_fragments_in_chat( + db: sqlite_utils.Database, prompt: str +) -> tuple[str, list[Fragment], list[Attachment]]: + """ + Process any !fragment commands in a chat prompt and return the modified prompt plus resolved fragments and attachments. + """ + prompt_lines = [] + fragments = [] + attachments = [] + for line in prompt.splitlines(): + if line.startswith("!fragment "): + try: + fragment_strs = line.strip().removeprefix("!fragment ").split() + fragments_and_attachments = resolve_fragments( + db, fragments=fragment_strs, allow_attachments=True + ) + fragments += [ + fragment + for fragment in fragments_and_attachments + if isinstance(fragment, Fragment) + ] + attachments += [ + attachment + for attachment in fragments_and_attachments + if isinstance(attachment, Attachment) + ] + except FragmentNotFound as ex: + raise click.ClickException(str(ex)) + else: + prompt_lines.append(line) + return "\n".join(prompt_lines), fragments, attachments + + +class AttachmentError(Exception): + """Exception raised for errors in attachment resolution.""" + + pass + + +def resolve_attachment(value): + """ + Resolve an attachment from a string value which could be: + - "-" for stdin + - A URL + - A file path + + Returns an Attachment object. + Raises AttachmentError if the attachment cannot be resolved. + """ + if value == "-": + content = sys.stdin.buffer.read() + # Try to guess type + mimetype = mimetype_from_string(content) + if mimetype is None: + raise AttachmentError("Could not determine mimetype of stdin") + return Attachment(type=mimetype, path=None, url=None, content=content) + + if "://" in value: + # Confirm URL exists and try to guess type + try: + response = httpx.head(value) + response.raise_for_status() + mimetype = response.headers.get("content-type") + except httpx.HTTPError as ex: + raise AttachmentError(str(ex)) + return Attachment(type=mimetype, path=None, url=value, content=None) + + # Check that the file exists + path = pathlib.Path(value) + if not path.exists(): + raise AttachmentError(f"File {value} does not exist") + path = path.resolve() + + # Try to guess type + mimetype = mimetype_from_path(str(path)) + if mimetype is None: + raise AttachmentError(f"Could not determine mimetype of {value}") + + return Attachment(type=mimetype, path=str(path), url=None, content=None) + + +class AttachmentType(click.ParamType): + name = "attachment" + + def convert(self, value, param, ctx): + try: + return resolve_attachment(value) + except AttachmentError as e: + self.fail(str(e), param, ctx) + + +def resolve_attachment_with_type(value: str, mimetype: str) -> Attachment: + if "://" in value: + attachment = Attachment(mimetype, None, value, None) + elif value == "-": + content = sys.stdin.buffer.read() + attachment = Attachment(mimetype, None, None, content) + else: + # Look for file + path = pathlib.Path(value) + if not path.exists(): + raise click.BadParameter(f"File {value} does not exist") + path = path.resolve() + attachment = Attachment(mimetype, str(path), None, None) + return attachment + + +def attachment_types_callback(ctx, param, values) -> List[Attachment]: + collected = [] + for value, mimetype in values: + collected.append(resolve_attachment_with_type(value, mimetype)) + return collected + + +def json_validator(object_name): + def validator(ctx, param, value): + if value is None: + return value + try: + obj = json.loads(value) + if not isinstance(obj, dict): + raise click.BadParameter(f"{object_name} must be a JSON object") + return obj + except json.JSONDecodeError: + raise click.BadParameter(f"{object_name} must be valid JSON") + + return validator + + +def schema_option(fn): + click.option( + "schema_input", + "--schema", + help="JSON schema, filepath or ID", + )(fn) + return fn + + +@click.group( + cls=DefaultGroup, + default="prompt", + default_if_no_args=True, + context_settings={"help_option_names": ["-h", "--help"]}, +) +@click.version_option() +def cli(): + """ + Access Large Language Models from the command-line + + Documentation: https://llm.datasette.io/ + + LLM can run models from many different providers. Consult the + plugin directory for a list of available models: + + https://llm.datasette.io/en/stable/plugins/directory.html + + To get started with OpenAI, obtain an API key from them and: + + \b + $ llm keys set openai + Enter key: ... + + Then execute a prompt like this: + + llm 'Five outrageous names for a pet pelican' + + For a full list of prompting options run: + + llm prompt --help + """ + + +@cli.command(name="prompt") +@click.argument("prompt", required=False) +@click.option("-s", "--system", help="System prompt to use") +@click.option("model_id", "-m", "--model", help="Model to use", envvar="LLM_MODEL") +@click.option( + "-d", + "--database", + type=click.Path(readable=True, dir_okay=False), + help="Path to log database", +) +@click.option( + "queries", + "-q", + "--query", + multiple=True, + help="Use first model matching these strings", +) +@click.option( + "attachments", + "-a", + "--attachment", + type=AttachmentType(), + multiple=True, + help="Attachment path or URL or -", +) +@click.option( + "attachment_types", + "--at", + "--attachment-type", + type=(str, str), + multiple=True, + callback=attachment_types_callback, + help="\b\nAttachment with explicit mimetype,\n--at image.jpg image/jpeg", +) +@click.option( + "tools", + "-T", + "--tool", + multiple=True, + help="Name of a tool to make available to the model", +) +@click.option( + "python_tools", + "--functions", + help="Python code block or file path defining functions to register as tools", + multiple=True, +) +@click.option( + "tools_debug", + "--td", + "--tools-debug", + is_flag=True, + help="Show full details of tool executions", + envvar="LLM_TOOLS_DEBUG", +) +@click.option( + "tools_approve", + "--ta", + "--tools-approve", + is_flag=True, + help="Manually approve every tool execution", +) +@click.option( + "chain_limit", + "--cl", + "--chain-limit", + type=int, + default=5, + help="How many chained tool responses to allow, default 5, set 0 for unlimited", +) +@click.option( + "options", + "-o", + "--option", + type=(str, str), + multiple=True, + help="key/value options for the model", +) +@click.option( + "show_model_options", + "--options", + is_flag=True, + help="Show options for the selected model", +) +@schema_option +@click.option( + "--schema-multi", + help="JSON schema to use for multiple results", +) +@click.option( + "fragments", + "-f", + "--fragment", + multiple=True, + help="Fragment (alias, URL, hash or file path) to add to the prompt", +) +@click.option( + "system_fragments", + "--sf", + "--system-fragment", + multiple=True, + help="Fragment to add to system prompt", +) +@click.option("-t", "--template", help="Template to use") +@click.option( + "-p", + "--param", + multiple=True, + type=(str, str), + help="Parameters for template", +) +@click.option("--no-stream", is_flag=True, help="Do not stream output") +@click.option("-n", "--no-log", is_flag=True, help="Don't log to database") +@click.option("--log", is_flag=True, help="Log prompt and response to the database") +@click.option("-R", "--hide-reasoning", is_flag=True, help="Hide reasoning output") +@click.option( + "_continue", + "-c", + "--continue", + is_flag=True, + flag_value=-1, + help="Continue the most recent conversation.", +) +@click.option( + "conversation_id", + "--cid", + "--conversation", + help="Continue the conversation with the given ID.", +) +@click.option("--key", help="API key to use") +@click.option("--save", help="Save prompt with this template name") +@click.option("async_", "--async", is_flag=True, help="Run prompt asynchronously") +@click.option("-u", "--usage", is_flag=True, help="Show token usage") +@click.option("-x", "--extract", is_flag=True, help="Extract first fenced code block") +@click.option( + "extract_last", + "--xl", + "--extract-last", + is_flag=True, + help="Extract last fenced code block", +) +def prompt( + prompt, + system, + model_id, + database, + queries, + attachments, + attachment_types, + tools, + python_tools, + tools_debug, + tools_approve, + chain_limit, + options, + show_model_options, + schema_input, + schema_multi, + fragments, + system_fragments, + template, + param, + no_stream, + no_log, + log, + hide_reasoning, + _continue, + conversation_id, + key, + save, + async_, + usage, + extract, + extract_last, +): + """ + 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") + + if queries and not model_id: + # Use -q options to find model with shortest model_id + matches = [] + for model_with_aliases in get_models_with_aliases(): + if all(model_with_aliases.matches(q) for q in queries): + matches.append(model_with_aliases.model.model_id) + if not matches: + raise click.ClickException( + "No model found matching queries {}".format(", ".join(queries)) + ) + model_id = min(matches, key=len) + + if show_model_options and not (conversation_id or _continue or template): + model_id = model_id or get_default_model() + try: + if async_: + get_async_model(model_id) + else: + get_model(model_id) + except UnknownModelError as ex: + raise click.ClickException(ex) + click.echo(render_model_with_options(model_id, async_=async_)) + return + + log_path = pathlib.Path(database) if database else logs_db_path() + (log_path.parent).mkdir(parents=True, exist_ok=True) + db = sqlite_utils.Database(log_path) + migrate(db) + + if schema_multi: + schema_input = schema_multi + + schema = resolve_schema_input(db, schema_input, load_template) + + if schema_multi: + # Convert that schema into multiple "items" of the same schema + schema = multi_schema(schema) + + def read_prompt(): + nonlocal prompt, schema + + # Is there extra prompt available on stdin? + stdin_prompt = None + if not sys.stdin.isatty(): + stdin_prompt = sys.stdin.read() + + if stdin_prompt: + bits = [stdin_prompt] + if prompt: + bits.append(prompt) + prompt = " ".join(bits) + + if ( + prompt is None + and not save + and sys.stdin.isatty() + and not attachments + and not attachment_types + and not schema + and not fragments + ): + # Hang waiting for input to stdin (unless --save) + prompt = sys.stdin.read() + return prompt + + if save: + # We are saving their prompt/system/etc to a new template + # Fields to save: prompt, system, model - and more in the future + disallowed_options = [] + for option, var in ( + ("--template", template), + ("--continue", _continue), + ("--cid", conversation_id), + ): + if var: + disallowed_options.append(option) + if disallowed_options: + raise click.ClickException( + "--save cannot be used with {}".format(", ".join(disallowed_options)) + ) + path = template_dir() / f"{save}.yaml" + to_save = {} + if model_id: + model_aliases = get_model_aliases() + try: + to_save["model"] = model_aliases[model_id].model_id + except KeyError: + raise click.ClickException("'{}' is not a known model".format(model_id)) + prompt = read_prompt() + if prompt: + to_save["prompt"] = prompt + if system: + to_save["system"] = system + if param: + to_save["defaults"] = dict(param) + if extract: + to_save["extract"] = True + if extract_last: + to_save["extract_last"] = True + if schema: + to_save["schema_object"] = schema + if fragments: + to_save["fragments"] = list(fragments) + if system_fragments: + to_save["system_fragments"] = list(system_fragments) + if python_tools: + to_save["functions"] = "\n\n".join(python_tools) + if tools: + to_save["tools"] = list(tools) + if attachments: + # Only works for attachments with a path or url + to_save["attachments"] = [ + (a.path or a.url) for a in attachments if (a.path or a.url) + ] + if attachment_types: + to_save["attachment_types"] = [ + {"type": a.type, "value": a.path or a.url} + for a in attachment_types + if (a.path or a.url) + ] + if options: + # Need to validate and convert their types first + model = get_model(model_id or get_default_model()) + try: + options_model = model.Options(**dict(options)) + # Use model_dump(mode="json") so Enums become their .value strings + to_save["options"] = { + k: v + for k, v in options_model.model_dump(mode="json").items() + if v is not None + } + except pydantic.ValidationError as ex: + raise click.ClickException(render_errors(ex.errors())) + path.write_text( + yaml.safe_dump( + to_save, + indent=4, + default_flow_style=False, + sort_keys=False, + ), + "utf-8", + ) + return + + if template: + params = dict(param) + # Cannot be used with system + try: + template_obj = load_template(template) + except LoadTemplateError as ex: + raise click.ClickException(str(ex)) + if not (extract or extract_last): + extract = template_obj.extract + extract_last = template_obj.extract_last + # Combine with template fragments/system_fragments + if template_obj.fragments: + fragments = [*template_obj.fragments, *fragments] + if template_obj.system_fragments: + system_fragments = [*template_obj.system_fragments, *system_fragments] + if template_obj.schema_object: + schema = template_obj.schema_object + if template_obj.tools: + tools = [*template_obj.tools, *tools] + if template_obj.functions and template_obj._functions_is_trusted: + python_tools = [template_obj.functions, *python_tools] + input_ = "" + if template_obj.options: + # Make options mutable (they start as a tuple) + options = list(options) + # Load any options, provided they were not set using -o already + specified_options = dict(options) + for option_name, option_value in template_obj.options.items(): + if option_name not in specified_options: + options.append((option_name, option_value)) + if "input" in template_obj.vars(): + input_ = read_prompt() + try: + template_prompt, template_system = template_obj.evaluate(input_, params) + if template_prompt: + # Combine with user prompt + if prompt and "input" not in template_obj.vars(): + prompt = template_prompt + "\n" + prompt + else: + prompt = template_prompt + if template_system and not system: + system = template_system + except Template.MissingVariables as ex: + raise click.ClickException(str(ex)) + if model_id is None and template_obj.model: + model_id = template_obj.model + # Merge in any attachments + if template_obj.attachments: + attachments = [ + resolve_attachment(a) for a in template_obj.attachments + ] + list(attachments) + if template_obj.attachment_types: + attachment_types = [ + resolve_attachment_with_type(at.value, at.type) + for at in template_obj.attachment_types + ] + list(attachment_types) + if extract or extract_last: + no_stream = True + + conversation = None + if conversation_id or _continue: + # Load the conversation - loads most recent if no ID provided + try: + conversation = load_conversation( + conversation_id, async_=async_, database=database + ) + except UnknownModelError as ex: + raise click.ClickException(str(ex)) + + if conversation_tools := _get_conversation_tools(conversation, tools): + tools = conversation_tools + + # Figure out which model we are using + if model_id is None: + if conversation: + model_id = conversation.model.model_id + else: + model_id = get_default_model() + + # Now resolve the model + try: + if async_: + model = get_async_model(model_id) + else: + model = get_model(model_id) + except UnknownModelError as ex: + raise click.ClickException(ex) + + if show_model_options: + click.echo(render_model_with_options(model_id, async_=async_)) + return + + 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 = {} + + resolved_attachments = [*attachments, *attachment_types] + + should_stream = model.can_stream and not no_stream + if not should_stream: + kwargs["stream"] = False + + if isinstance(model, (KeyModel, AsyncKeyModel)): + kwargs["key"] = key + + prompt = read_prompt() + response = None + + try: + fragments_and_attachments = resolve_fragments( + db, fragments, allow_attachments=True + ) + resolved_fragments = [ + fragment + for fragment in fragments_and_attachments + if isinstance(fragment, Fragment) + ] + resolved_attachments.extend( + attachment + for attachment in fragments_and_attachments + if isinstance(attachment, Attachment) + ) + resolved_system_fragments = resolve_fragments(db, system_fragments) + except FragmentNotFound as ex: + raise click.ClickException(str(ex)) + + prompt_method = model.prompt + if conversation: + prompt_method = conversation.prompt + + tool_implementations = _gather_tools(tools, python_tools) + + if tool_implementations: + prompt_method = conversation.chain + kwargs["options"] = validated_options + kwargs["chain_limit"] = chain_limit + if tools_debug: + kwargs["after_call"] = _debug_tool_call + if tools_approve: + kwargs["before_call"] = _approve_tool_call + kwargs["tools"] = tool_implementations + else: + # Merge in options for the .prompt() methods + kwargs.update(validated_options) + + if hide_reasoning: + kwargs["hide_reasoning"] = True + + try: + if async_: + + async def inner(): + if should_stream: + response = prompt_method( + prompt, + attachments=resolved_attachments, + system=system, + schema=schema, + fragments=resolved_fragments, + system_fragments=resolved_system_fragments, + **kwargs, + ) + await display_async_stream_events( + response.astream_events(), + show_reasoning=not hide_reasoning, + ) + print("") + else: + response = prompt_method( + prompt, + fragments=resolved_fragments, + attachments=resolved_attachments, + schema=schema, + system=system, + system_fragments=resolved_system_fragments, + **kwargs, + ) + text = await response.text() + if extract or extract_last: + text = ( + extract_fenced_code_block(text, last=extract_last) or text + ) + print(text) + return response + + response = asyncio.run(inner()) + else: + response = prompt_method( + prompt, + fragments=resolved_fragments, + attachments=resolved_attachments, + system=system, + schema=schema, + system_fragments=resolved_system_fragments, + **kwargs, + ) + if should_stream: + display_stream_events( + response.stream_events(), + show_reasoning=not hide_reasoning, + ) + print("") + else: + text = response.text() + if extract or extract_last: + text = extract_fenced_code_block(text, last=extract_last) or text + print(text) + # List of exceptions that should never be raised in pytest: + except (ValueError, NotImplementedError) as ex: + raise click.ClickException(str(ex)) + except Exception as ex: + # All other exceptions should raise in pytest, show to user otherwise + if getattr(sys, "_called_from_test", False) or os.environ.get( + "LLM_RAISE_ERRORS", None + ): + raise + raise click.ClickException(str(ex)) + + if usage: + if isinstance(response, ChainResponse): + responses = response._responses + else: + responses = [response] + for response_object in responses: + # Show token usage to stderr in yellow + click.echo( + click.style( + "Token usage: {}".format(response_object.token_usage()), + fg="yellow", + bold=True, + ), + err=True, + ) + + # Log responses to the database + if (logs_on() or log) and not no_log: + # Could be Response, AsyncResponse, ChainResponse, AsyncChainResponse + if isinstance(response, AsyncResponse): + response = asyncio.run(response.to_sync_response()) + # At this point ALL forms should have a log_to_db() method that works: + response.log_to_db(db) + + +@cli.command() +@click.option("-s", "--system", help="System prompt to use") +@click.option("model_id", "-m", "--model", help="Model to use", envvar="LLM_MODEL") +@click.option( + "_continue", + "-c", + "--continue", + is_flag=True, + flag_value=-1, + help="Continue the most recent conversation.", +) +@click.option( + "conversation_id", + "--cid", + "--conversation", + help="Continue the conversation with the given ID.", +) +@click.option( + "fragments", + "-f", + "--fragment", + multiple=True, + help="Fragment (alias, URL, hash or file path) to add to the prompt", +) +@click.option( + "system_fragments", + "--sf", + "--system-fragment", + multiple=True, + help="Fragment to add to system prompt", +) +@click.option("-t", "--template", help="Template to use") +@click.option( + "-p", + "--param", + multiple=True, + type=(str, str), + help="Parameters for template", +) +@click.option( + "options", + "-o", + "--option", + type=(str, str), + multiple=True, + help="key/value options for the model", +) +@click.option( + "-d", + "--database", + type=click.Path(readable=True, dir_okay=False), + help="Path to log database", +) +@click.option("--no-stream", is_flag=True, help="Do not stream output") +@click.option("-R", "--hide-reasoning", is_flag=True, help="Hide reasoning output") +@click.option("--key", help="API key to use") +@click.option( + "tools", + "-T", + "--tool", + multiple=True, + help="Name of a tool to make available to the model", +) +@click.option( + "python_tools", + "--functions", + help="Python code block or file path defining functions to register as tools", + multiple=True, +) +@click.option( + "tools_debug", + "--td", + "--tools-debug", + is_flag=True, + help="Show full details of tool executions", + envvar="LLM_TOOLS_DEBUG", +) +@click.option( + "tools_approve", + "--ta", + "--tools-approve", + is_flag=True, + help="Manually approve every tool execution", +) +@click.option( + "chain_limit", + "--cl", + "--chain-limit", + type=int, + default=5, + help="How many chained tool responses to allow, default 5, set 0 for unlimited", +) +def chat( + system, + model_id, + _continue, + conversation_id, + fragments, + system_fragments, + template, + param, + options, + no_stream, + hide_reasoning, + key, + database, + tools, + python_tools, + tools_debug, + tools_approve, + chain_limit, +): + """ + Hold an ongoing chat with a model. + """ + # Left and right arrow keys to move cursor: + if sys.platform != "win32": + readline.parse_and_bind("\\e[D: backward-char") + readline.parse_and_bind("\\e[C: forward-char") + else: + readline.parse_and_bind("bind -x '\\e[D: backward-char'") + readline.parse_and_bind("bind -x '\\e[C: forward-char'") + log_path = pathlib.Path(database) if database else logs_db_path() + (log_path.parent).mkdir(parents=True, exist_ok=True) + db = sqlite_utils.Database(log_path) + migrate(db) + + conversation = None + if conversation_id or _continue: + # Load the conversation - loads most recent if no ID provided + try: + conversation = load_conversation(conversation_id, database=database) + except UnknownModelError as ex: + raise click.ClickException(str(ex)) + + if conversation_tools := _get_conversation_tools(conversation, tools): + tools = conversation_tools + + template_obj = None + if template: + params = dict(param) + try: + template_obj = load_template(template) + except LoadTemplateError as ex: + raise click.ClickException(str(ex)) + if model_id is None and template_obj.model: + model_id = template_obj.model + if template_obj.tools: + tools = [*template_obj.tools, *tools] + if template_obj.functions and template_obj._functions_is_trusted: + python_tools = [template_obj.functions, *python_tools] + + # Figure out which model we are using + if model_id is None: + if conversation: + model_id = conversation.model.model_id + else: + model_id = get_default_model() + + # Now resolve the model + try: + model = get_model(model_id) + except KeyError: + raise click.ClickException("'{}' is not a known model".format(model_id)) + + if conversation is None: + # Start a fresh conversation for this chat + conversation = Conversation(model=model) + else: + # Ensure it can see the API key + conversation.model = model + + if tools_debug: + conversation.after_call = _debug_tool_call + if tools_approve: + conversation.before_call = _approve_tool_call + + # Validate options + validated_options = get_model_options(model.model_id) + if options: + try: + validated_options = dict( + (key, value) + for key, value in model.Options(**dict(options)) + if value is not None + ) + except pydantic.ValidationError as ex: + raise click.ClickException(render_errors(ex.errors())) + + kwargs = {} + if validated_options: + kwargs["options"] = validated_options + + tool_functions = _gather_tools(tools, python_tools) + + if tool_functions: + kwargs["chain_limit"] = chain_limit + kwargs["tools"] = tool_functions + + should_stream = model.can_stream and not no_stream + if not should_stream: + kwargs["stream"] = False + + if key and isinstance(model, KeyModel): + kwargs["key"] = key + if hide_reasoning: + kwargs["hide_reasoning"] = True + + try: + fragments_and_attachments = resolve_fragments( + db, fragments, allow_attachments=True + ) + argument_fragments = [ + fragment + for fragment in fragments_and_attachments + if isinstance(fragment, Fragment) + ] + argument_attachments = [ + attachment + for attachment in fragments_and_attachments + if isinstance(attachment, Attachment) + ] + argument_system_fragments = resolve_fragments(db, system_fragments) + except FragmentNotFound as ex: + raise click.ClickException(str(ex)) + + click.echo("Chatting with {}".format(model.model_id)) + click.echo("Type 'exit' or 'quit' to exit") + click.echo("Type '!multi' to enter multiple lines, then '!end' to finish") + click.echo("Type '!edit' to open your default editor and modify the prompt") + click.echo( + "Type '!fragment [ ...]' to insert one or more fragments" + ) + in_multi = False + + accumulated = [] + accumulated_fragments = [] + accumulated_attachments = [] + end_token = "!end" + while True: + prompt = click.prompt("", prompt_suffix="> " if not in_multi else "") + fragments = [] + attachments = [] + if argument_fragments: + fragments += argument_fragments + # fragments from --fragments will get added to the first message only + argument_fragments = [] + if argument_attachments: + attachments = argument_attachments + argument_attachments = [] + if prompt.strip().startswith("!multi"): + in_multi = True + bits = prompt.strip().split() + if len(bits) > 1: + end_token = "!end {}".format(" ".join(bits[1:])) + continue + if prompt.strip() == "!edit": + edited_prompt = click.edit() + if edited_prompt is None: + click.echo("Editor closed without saving.", err=True) + continue + prompt = edited_prompt.strip() + if prompt.strip().startswith("!fragment "): + prompt, fragments, attachments = process_fragments_in_chat(db, prompt) + + if in_multi: + if prompt.strip() == end_token: + prompt = "\n".join(accumulated) + fragments = accumulated_fragments + attachments = accumulated_attachments + in_multi = False + accumulated = [] + accumulated_fragments = [] + accumulated_attachments = [] + else: + if prompt: + accumulated.append(prompt) + accumulated_fragments += fragments + accumulated_attachments += attachments + continue + if template_obj: + try: + # Mirror prompt() logic: only pass input if template uses it + uses_input = "input" in template_obj.vars() + input_ = prompt if uses_input else "" + template_prompt, template_system = template_obj.evaluate(input_, params) + except Template.MissingVariables as ex: + raise click.ClickException(str(ex)) + if template_system and not system: + system = template_system + if template_prompt: + if prompt and not uses_input: + prompt = f"{template_prompt}\n{prompt}" + else: + prompt = template_prompt + if prompt.strip() in ("exit", "quit"): + break + + response = conversation.chain( + prompt, + fragments=fragments, + system_fragments=argument_system_fragments, + attachments=attachments, + system=system, + **kwargs, + ) + + # System prompt and system fragments only sent for the first message + system = None + argument_system_fragments = [] + display_stream_events( + response.stream_events(), + show_reasoning=not hide_reasoning, + ) + response.log_to_db(db) + print("") + + +def load_conversation( + conversation_id: Optional[str], + async_=False, + database=None, +) -> Optional[_BaseConversation]: + log_path = pathlib.Path(database) if database else logs_db_path() + db = sqlite_utils.Database(log_path) + migrate(db) + if conversation_id is None: + # Return the most recent conversation, or None if there are none + matches = list(db["conversations"].rows_where(order_by="id desc", limit=1)) + if matches: + conversation_id = matches[0]["id"] + else: + return None + try: + row = cast(sqlite_utils.db.Table, db["conversations"]).get(conversation_id) + except sqlite_utils.db.NotFoundError: + raise click.ClickException( + "No conversation found with id={}".format(conversation_id) + ) + # Inflate that conversation + conversation_class = AsyncConversation if async_ else Conversation + response_class = AsyncResponse if async_ else Response + conversation = conversation_class.from_row(row) + for response in db["responses"].rows_where( + "conversation_id = ?", [conversation_id], order_by="id" + ): + response_obj = response_class.from_row(db, response) + if conversation.responses: + previous_response = conversation.responses[-1] + # SQLite rows store each response's legacy current-turn inputs + # (prompt text, attachments, tool_results), not the full + # prompt.messages chain. Rebuild that chain here so follow-up + # prompts via `llm -c` satisfy the Prompt.messages invariant. + response_obj.prompt._explicit_messages = ( + list(previous_response.prompt.messages) + + list(previous_response._messages_now()) + + list(response_obj.prompt.messages) + ) + conversation.responses.append(response_obj) + return conversation + + +@cli.group( + cls=DefaultGroup, + default="list", + default_if_no_args=True, +) +def keys(): + "Manage stored API keys for different models" + + +@keys.command(name="list") +def keys_list(): + "List names of all stored keys" + path = user_dir() / "keys.json" + if not path.exists(): + click.echo("No keys found") + return + keys = json.loads(path.read_text()) + for key in sorted(keys.keys()): + if key != "// Note": + click.echo(key) + + +@keys.command(name="path") +def keys_path_command(): + "Output the path to the keys.json file" + click.echo(user_dir() / "keys.json") + + +@keys.command(name="get") +@click.argument("name") +def keys_get(name): + """ + Return the value of a stored key + + Example usage: + + \b + export OPENAI_API_KEY=$(llm keys get openai) + """ + path = user_dir() / "keys.json" + if not path.exists(): + raise click.ClickException("No keys found") + keys = json.loads(path.read_text()) + try: + click.echo(keys[name]) + except KeyError: + raise click.ClickException("No key found with name '{}'".format(name)) + + +@keys.command(name="set") +@click.argument("name") +@click.option("--value", prompt="Enter key", hide_input=True, help="Value to set") +def keys_set(name, value): + """ + Save a key in the keys.json file + + Example usage: + + \b + $ llm keys set openai + Enter key: ... + """ + default = {"// Note": "This file stores secret API credentials. Do not share!"} + path = user_dir() / "keys.json" + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + path.write_text(json.dumps(default)) + path.chmod(0o600) + try: + current = json.loads(path.read_text()) + except json.decoder.JSONDecodeError: + current = default + current[name] = value + path.write_text(json.dumps(current, indent=2) + "\n") + + +@cli.group( + cls=DefaultGroup, + default="list", + default_if_no_args=True, +) +def logs(): + "Tools for exploring logged prompts and responses" + + +@logs.command(name="path") +def logs_path(): + "Output the path to the logs.db file" + click.echo(logs_db_path()) + + +@logs.command(name="status") +def logs_status(): + "Show current status of database logging" + path = logs_db_path() + if not path.exists(): + click.echo("No log database found at {}".format(path)) + return + if logs_on(): + click.echo("Logging is ON for all prompts".format()) + else: + click.echo("Logging is OFF".format()) + db = sqlite_utils.Database(path) + migrate(db) + click.echo("Found log database at {}".format(path)) + click.echo("Number of conversations logged:\t{}".format(db["conversations"].count)) + click.echo("Number of responses logged:\t{}".format(db["responses"].count)) + click.echo( + "Database file size: \t\t{}".format(_human_readable_size(path.stat().st_size)) + ) + + +@logs.command(name="backup") +@click.argument("path", type=click.Path(dir_okay=True, writable=True)) +def backup(path): + "Backup your logs database to this file" + logs_path = logs_db_path() + path = pathlib.Path(path) + db = sqlite_utils.Database(logs_path) + try: + db.execute("vacuum into ?", [str(path)]) + except Exception as ex: + raise click.ClickException(str(ex)) + click.echo( + "Backed up {} to {}".format(_human_readable_size(path.stat().st_size), path) + ) + + +@logs.command(name="on") +def logs_turn_on(): + "Turn on logging for all prompts" + path = user_dir() / "logs-off" + if path.exists(): + path.unlink() + + +@logs.command(name="off") +def logs_turn_off(): + "Turn off logging for all prompts" + path = user_dir() / "logs-off" + path.touch() + + +LOGS_COLUMNS = """ responses.id, + responses.model, + responses.resolved_model, + responses.prompt, + responses.system, + responses.prompt_json, + responses.options_json, + responses.response, + responses.reasoning, + responses.response_json, + responses.conversation_id, + responses.duration_ms, + responses.datetime_utc, + responses.input_tokens, + responses.output_tokens, + responses.token_details, + conversations.name as conversation_name, + conversations.model as conversation_model, + schemas.content as schema_json""" + +LOGS_SQL = """ +select +{columns} +from + responses +left join schemas on responses.schema_id = schemas.id +left join conversations on responses.conversation_id = conversations.id{extra_where} +order by {order_by}{limit} +""" +LOGS_SQL_SEARCH = """ +select +{columns} +from + responses +left join schemas on responses.schema_id = schemas.id +left join conversations on responses.conversation_id = conversations.id +join responses_fts on responses_fts.rowid = responses.rowid +where responses_fts match :query{extra_where} +order by {order_by}{limit} +""" + +ATTACHMENTS_SQL = """ +select + response_id, + attachments.id, + attachments.type, + attachments.path, + attachments.url, + length(attachments.content) as content_length +from attachments +join prompt_attachments + on attachments.id = prompt_attachments.attachment_id +where prompt_attachments.response_id in ({}) +order by prompt_attachments."order" +""" + + +@logs.command(name="list") +@click.option( + "-n", + "--count", + type=int, + default=None, + help="Number of entries to show - defaults to 3, use 0 for all", +) +@click.option( + "-p", + "--path", + type=click.Path(readable=True, exists=True, dir_okay=False), + help="Path to log database", + hidden=True, +) +@click.option( + "-d", + "--database", + type=click.Path(readable=True, exists=True, dir_okay=False), + help="Path to log database", +) +@click.option("-m", "--model", help="Filter by model or model alias") +@click.option("-q", "--query", help="Search for logs matching this string") +@click.option( + "fragments", + "--fragment", + "-f", + help="Filter for prompts using these fragments", + multiple=True, +) +@click.option( + "tools", + "-T", + "--tool", + multiple=True, + help="Filter for prompts with results from these tools", +) +@click.option( + "any_tools", + "--tools", + is_flag=True, + help="Filter for prompts with results from any tools", +) +@schema_option +@click.option( + "--schema-multi", + help="JSON schema used for multiple results", +) +@click.option( + "-l", "--latest", is_flag=True, help="Return latest results matching search query" +) +@click.option( + "--data", is_flag=True, help="Output newline-delimited JSON data for schema" +) +@click.option("--data-array", is_flag=True, help="Output JSON array of data for schema") +@click.option("--data-key", help="Return JSON objects from array in this key") +@click.option( + "--data-ids", is_flag=True, help="Attach corresponding IDs to JSON objects" +) +@click.option("-t", "--truncate", is_flag=True, help="Truncate long strings in output") +@click.option( + "-s", "--short", is_flag=True, help="Shorter YAML output with truncated prompts" +) +@click.option("-u", "--usage", is_flag=True, help="Include token usage") +@click.option("-r", "--response", is_flag=True, help="Just output the last response") +@click.option("-x", "--extract", is_flag=True, help="Extract first fenced code block") +@click.option( + "extract_last", + "--xl", + "--extract-last", + is_flag=True, + help="Extract last fenced code block", +) +@click.option( + "current_conversation", + "-c", + "--current", + is_flag=True, + flag_value=-1, + help="Show logs from the current conversation", +) +@click.option( + "conversation_id", + "--cid", + "--conversation", + help="Show logs for this conversation ID", +) +@click.option("--id-gt", help="Return responses with ID > this") +@click.option("--id-gte", help="Return responses with ID >= this") +@click.option( + "json_output", + "--json", + is_flag=True, + help="Output logs as JSON", +) +@click.option( + "--expand", + "-e", + is_flag=True, + help="Expand fragments to show their content", +) +def logs_list( + count, + path, + database, + model, + query, + fragments, + tools, + any_tools, + schema_input, + schema_multi, + latest, + data, + data_array, + data_key, + data_ids, + truncate, + short, + usage, + response, + extract, + extract_last, + current_conversation, + conversation_id, + id_gt, + id_gte, + json_output, + expand, +): + "Show logged prompts and their responses" + if database and not path: + path = database + path = pathlib.Path(path or logs_db_path()) + if not path.exists(): + raise click.ClickException("No log database found at {}".format(path)) + db = sqlite_utils.Database(path) + migrate(db) + + if schema_multi: + schema_input = schema_multi + schema = resolve_schema_input(db, schema_input, load_template) + if schema_multi: + schema = multi_schema(schema) + + if short and (json_output or response): + invalid = " or ".join( + [ + flag[0] + for flag in (("--json", json_output), ("--response", response)) + if flag[1] + ] + ) + raise click.ClickException("Cannot use --short and {} together".format(invalid)) + + if response and not current_conversation and not conversation_id: + current_conversation = True + + if current_conversation: + try: + conversation_id = next( + db.query( + "select conversation_id from responses order by id desc limit 1" + ) + )["conversation_id"] + except StopIteration: + # No conversations yet + raise click.ClickException("No conversations found") + + # For --conversation set limit 0, if not explicitly set + if count is None: + if conversation_id: + count = 0 + else: + count = 3 + + model_id = None + if model: + # Resolve alias, if any + try: + model_id = get_model(model).model_id + except UnknownModelError: + # Maybe they uninstalled a model, use the -m option as-is + model_id = model + + sql = LOGS_SQL + order_by = "responses.id desc" + if query: + sql = LOGS_SQL_SEARCH + if not latest: + order_by = "responses_fts.rank desc" + + limit = "" + if count is not None and count > 0: + limit = " limit {}".format(count) + + sql_format = { + "limit": limit, + "columns": LOGS_COLUMNS, + "extra_where": "", + "order_by": order_by, + } + where_bits = [] + sql_params = { + "model": model_id, + "query": query, + "conversation_id": conversation_id, + "id_gt": id_gt, + "id_gte": id_gte, + } + if model_id: + where_bits.append("responses.model = :model") + if conversation_id: + where_bits.append("responses.conversation_id = :conversation_id") + if id_gt: + where_bits.append("responses.id > :id_gt") + if id_gte: + where_bits.append("responses.id >= :id_gte") + if fragments: + # Resolve the fragments to their hashes + fragment_hashes = [ + fragment.id() for fragment in resolve_fragments(db, fragments) + ] + exists_clauses = [] + + for i, fragment_hash in enumerate(fragment_hashes): + exists_clause = f""" + exists ( + select 1 from prompt_fragments + where prompt_fragments.response_id = responses.id + and prompt_fragments.fragment_id in ( + select fragments.id from fragments + where hash = :f{i} + ) + union + select 1 from system_fragments + where system_fragments.response_id = responses.id + and system_fragments.fragment_id in ( + select fragments.id from fragments + where hash = :f{i} + ) + ) + """ + exists_clauses.append(exists_clause) + sql_params["f{}".format(i)] = fragment_hash + + where_bits.append(" and ".join(exists_clauses)) + + if any_tools: + # Any response that involved at least one tool result + where_bits.append(""" + exists ( + select 1 + from tool_results + where + tool_results.response_id = responses.id + ) + """) + if tools: + tools_by_name = get_tools() + # Filter responses by tools (must have ALL of the named tools, including plugin) + tool_clauses = [] + for i, tool_name in enumerate(tools): + try: + plugin_name = tools_by_name[tool_name].plugin + except KeyError: + raise click.ClickException(f"Unknown tool: {tool_name}") + + tool_clauses.append(f""" + exists ( + select 1 + from tool_results + join tools on tools.id = tool_results.tool_id + where tool_results.response_id = responses.id + and tools.name = :tool{i} + and tools.plugin = :plugin{i} + ) + """) + sql_params[f"tool{i}"] = tool_name + sql_params[f"plugin{i}"] = plugin_name + + # AND means “must have all” — use OR instead if you want “any of” + where_bits.append(" and ".join(tool_clauses)) + + schema_id = None + if schema: + schema_id = make_schema_id(schema)[0] + where_bits.append("responses.schema_id = :schema_id") + sql_params["schema_id"] = schema_id + + if where_bits: + where_ = " and " if query else " where " + sql_format["extra_where"] = where_ + " and ".join(where_bits) + + final_sql = sql.format(**sql_format) + rows = list(db.query(final_sql, sql_params)) + + # Reverse the order - we do this because we 'order by id desc limit 3' to get the + # 3 most recent results, but we still want to display them in chronological order + # ... except for searches where we don't do this + if not query and not data: + rows.reverse() + + # Fetch any attachments + ids = [row["id"] for row in rows] + attachments = list(db.query(ATTACHMENTS_SQL.format(",".join("?" * len(ids))), ids)) + attachments_by_id = {} + for attachment in attachments: + attachments_by_id.setdefault(attachment["response_id"], []).append(attachment) + + FRAGMENTS_SQL = """ + select + {table}.response_id, + fragments.hash, + fragments.id as fragment_id, + fragments.content, + ( + select json_group_array(fragment_aliases.alias) + from fragment_aliases + where fragment_aliases.fragment_id = fragments.id + ) as aliases + from {table} + join fragments on {table}.fragment_id = fragments.id + where {table}.response_id in ({placeholders}) + order by {table}."order" + """ + + # Fetch any prompt or system prompt fragments + prompt_fragments_by_id = {} + system_fragments_by_id = {} + for table, dictionary in ( + ("prompt_fragments", prompt_fragments_by_id), + ("system_fragments", system_fragments_by_id), + ): + for fragment in db.query( + FRAGMENTS_SQL.format(placeholders=",".join("?" * len(ids)), table=table), + ids, + ): + dictionary.setdefault(fragment["response_id"], []).append(fragment) + + if data or data_array or data_key or data_ids: + # Special case for --data to output valid JSON + to_output = [] + for row in rows: + response = row["response"] or "" + try: + decoded = json.loads(response) + new_items = [] + if ( + isinstance(decoded, dict) + and (data_key in decoded) + and all(isinstance(item, dict) for item in decoded[data_key]) + ): + for item in decoded[data_key]: + new_items.append(item) + else: + new_items.append(decoded) + if data_ids: + for item in new_items: + item[find_unused_key(item, "response_id")] = row["id"] + item[find_unused_key(item, "conversation_id")] = row["id"] + to_output.extend(new_items) + except ValueError: + pass + for line in output_rows_as_json(to_output, nl=not data_array, compact=True): + click.echo(line) + return + + # Tool usage information + TOOLS_SQL = """ + SELECT responses.id, + -- Tools related to this response + COALESCE( + (SELECT json_group_array(json_object( + 'id', t.id, + 'hash', t.hash, + 'name', t.name, + 'description', t.description, + 'input_schema', json(t.input_schema) + )) + FROM tools t + JOIN tool_responses tr ON t.id = tr.tool_id + WHERE tr.response_id = responses.id + ), + '[]' + ) AS tools, + -- Tool calls for this response + COALESCE( + (SELECT json_group_array(json_object( + 'id', tc.id, + 'tool_id', tc.tool_id, + 'name', tc.name, + 'arguments', json(tc.arguments), + 'tool_call_id', tc.tool_call_id + )) + FROM tool_calls tc + WHERE tc.response_id = responses.id + ), + '[]' + ) AS tool_calls, + -- Tool results for this response + COALESCE( + (SELECT json_group_array(json_object( + 'id', tr.id, + 'tool_id', tr.tool_id, + 'name', tr.name, + 'output', tr.output, + 'tool_call_id', tr.tool_call_id, + 'exception', tr.exception, + 'attachments', COALESCE( + (SELECT json_group_array(json_object( + 'id', a.id, + 'type', a.type, + 'path', a.path, + 'url', a.url, + 'content', a.content + )) + FROM tool_results_attachments tra + JOIN attachments a ON tra.attachment_id = a.id + WHERE tra.tool_result_id = tr.id + ), + '[]' + ) + )) + FROM tool_results tr + WHERE tr.response_id = responses.id + ), + '[]' + ) AS tool_results + FROM responses + where id in ({placeholders}) + """ + tool_info_by_id = { + row["id"]: { + "tools": json.loads(row["tools"]), + "tool_calls": json.loads(row["tool_calls"]), + "tool_results": json.loads(row["tool_results"]), + } + for row in db.query( + TOOLS_SQL.format(placeholders=",".join("?" * len(ids))), ids + ) + } + + for row in rows: + if truncate: + row["prompt"] = truncate_string(row["prompt"] or "") + row["response"] = truncate_string(row["response"] or "") + # Add prompt and system fragments + for key in ("prompt_fragments", "system_fragments"): + row[key] = [ + { + "hash": fragment["hash"], + "content": ( + fragment["content"] + if expand + else truncate_string(fragment["content"]) + ), + "aliases": json.loads(fragment["aliases"]), + } + for fragment in ( + prompt_fragments_by_id.get(row["id"], []) + if key == "prompt_fragments" + else system_fragments_by_id.get(row["id"], []) + ) + ] + # Either decode or remove all JSON keys + keys = list(row.keys()) + for key in keys: + if key.endswith("_json") and row[key] is not None: + if truncate: + del row[key] + else: + row[key] = json.loads(row[key]) + row.update(tool_info_by_id[row["id"]]) + + output = None + if json_output: + # Output as JSON if requested + for row in rows: + row["attachments"] = [ + {k: v for k, v in attachment.items() if k != "response_id"} + for attachment in attachments_by_id.get(row["id"], []) + ] + output = json.dumps(list(rows), indent=2) + elif extract or extract_last: + # Extract and return first code block + for row in rows: + output = extract_fenced_code_block(row["response"], last=extract_last) + if output is not None: + break + elif response: + # Just output the last response + if rows: + output = rows[-1]["response"] + + if output is not None: + click.echo(output) + else: + # Output neatly formatted human-readable logs + def _fenced_block(value): + # Fenced code block, indented to nest inside a list item + num_backticks = 3 + while "`" * num_backticks in value: + num_backticks += 1 + fence = "`" * num_backticks + return textwrap.indent("{}\n{}\n{}".format(fence, value, fence), " ") + + def _inline_code(value): + num_backticks = 1 + while "`" * num_backticks in value: + num_backticks += 1 + delimiter = "`" * num_backticks + if value.startswith("`") or value.endswith("`"): + return "{} {} {}".format(delimiter, value, delimiter) + return "{}{}{}".format(delimiter, value, delimiter) + + def _format_tool_call_arguments(arguments): + if not isinstance(arguments, dict) or not arguments: + return " Arguments: {}".format(_inline_code(json.dumps(arguments))) + lines = [] + for key, value in arguments.items(): + if isinstance(value, str): + lines.append(" {}:".format(key)) + lines.append(_fenced_block(value)) + else: + lines.append( + " {}: {}".format(key, _inline_code(json.dumps(value))) + ) + return "\n".join(lines) + + def _token_usage_markdown(input_tokens, output_tokens, token_details): + usage = token_usage_string(input_tokens, output_tokens, None) + if token_details: + details = _inline_code(json.dumps(token_details)) + if usage: + return "{}, {}".format(usage, details) + return details + return usage + + def _display_fragments(fragments, title): + if not fragments: + return + if not expand: + content = "\n".join( + ["- {}".format(fragment["hash"]) for fragment in fragments] + ) + else: + #
for each one + bits = [] + for fragment in fragments: + bits.append( + "
{}\n{}\n
".format( + fragment["hash"], maybe_fenced_code(fragment["content"]) + ) + ) + content = "\n".join(bits) + click.echo(f"\n### {title}\n\n{content}") + + current_system = None + should_show_conversation = True + seen_tool_hashes = set() + for row in rows: + if short: + system = truncate_string( + row["system"] or "", 120, normalize_whitespace=True + ) + prompt = truncate_string( + row["prompt"] or "", 120, normalize_whitespace=True, keep_end=True + ) + cid = row["conversation_id"] + attachments = attachments_by_id.get(row["id"]) + obj = { + "model": row["model"], + "datetime": row["datetime_utc"].split(".")[0], + "conversation": cid, + } + if row["tool_calls"]: + obj["tool_calls"] = [ + "{}({})".format( + tool_call["name"], json.dumps(tool_call["arguments"]) + ) + for tool_call in row["tool_calls"] + ] + if row["tool_results"]: + obj["tool_results"] = [ + "{}: {}".format( + tool_result["name"], truncate_string(tool_result["output"]) + ) + for tool_result in row["tool_results"] + ] + if system: + obj["system"] = system + if prompt: + obj["prompt"] = prompt + if attachments: + items = [] + for attachment in attachments: + details = {"type": attachment["type"]} + if attachment.get("path"): + details["path"] = attachment["path"] + if attachment.get("url"): + details["url"] = attachment["url"] + items.append(details) + obj["attachments"] = items + for key in ("prompt_fragments", "system_fragments"): + obj[key] = [fragment["hash"] for fragment in row[key]] + if usage and (row["input_tokens"] or row["output_tokens"]): + usage_details = { + "input": row["input_tokens"], + "output": row["output_tokens"], + } + if row["token_details"]: + usage_details["details"] = json.loads(row["token_details"]) + obj["usage"] = usage_details + click.echo(yaml.dump([obj], sort_keys=False).strip()) + continue + # Not short, output Markdown + click.echo( + "# {}{}\n{}".format( + row["datetime_utc"].split(".")[0], + ( + " conversation: {} id: {}".format( + row["conversation_id"], row["id"] + ) + if should_show_conversation + else "" + ), + ( + ( + "\nModel: **{}**{}\n".format( + row["model"], + ( + " (resolved: **{}**)".format(row["resolved_model"]) + if row["resolved_model"] + else "" + ), + ) + ) + if should_show_conversation + else "" + ), + ) + ) + # In conversation log mode only show it for the first one + if conversation_id: + should_show_conversation = False + click.echo("## Prompt\n\n{}".format(row["prompt"] or "-- none --")) + _display_fragments(row["prompt_fragments"], "Prompt fragments") + if row["options_json"]: + options = row["options_json"] + if isinstance(options, str): + options = json.loads(options) + if options: + options_text = "\n".join( + "- {}: {}".format(key, value) for key, value in options.items() + ) + click.echo("\n## Options\n\n{}".format(options_text)) + if row["system"] != current_system: + if row["system"] is not None: + click.echo("\n## System\n\n{}".format(row["system"])) + current_system = row["system"] + _display_fragments(row["system_fragments"], "System fragments") + if row["schema_json"]: + click.echo( + "\n## Schema\n\n```json\n{}\n```".format( + json.dumps(row["schema_json"], indent=2) + ) + ) + # Show tool calls and results + if row["tools"]: + click.echo("\n### Tools\n") + for tool in row["tools"]: + if tool["hash"] in seen_tool_hashes: + click.echo( + "- **{}**: `{}`".format(tool["name"], tool["hash"][:7]) + ) + else: + seen_tool_hashes.add(tool["hash"]) + click.echo( + "- **{}**: `{}`
\n{}
\n Arguments: `{}`".format( + tool["name"], + tool["hash"], + textwrap.indent( + (tool["description"] or "").rstrip(), " " + ), + json.dumps(tool["input_schema"]["properties"]), + ) + ) + if row["tool_results"]: + click.echo("\n### Tool results\n") + for tool_result in row["tool_results"]: + attachments = "" + for attachment in tool_result["attachments"]: + desc = "" + if attachment.get("type"): + desc += attachment["type"] + ": " + if attachment.get("path"): + desc += attachment["path"] + elif attachment.get("url"): + desc += attachment["url"] + elif attachment.get("content"): + desc += f"<{attachment['content_length']:,} bytes>" + attachments += "\n - {}".format(desc) + click.echo( + "- **{}**: `{}`
\n{}{}{}".format( + tool_result["name"], + tool_result["tool_call_id"], + _fenced_block(tool_result["output"]), + ( + "
\n **Error**: {}\n".format( + tool_result["exception"] + ) + if tool_result["exception"] + else "" + ), + attachments, + ) + ) + attachments = attachments_by_id.get(row["id"]) + if attachments: + click.echo("\n### Attachments\n") + for i, attachment in enumerate(attachments, 1): + if attachment["path"]: + path = attachment["path"] + click.echo( + "{}. **{}**: `{}`".format(i, attachment["type"], path) + ) + elif attachment["url"]: + click.echo( + "{}. **{}**: {}".format( + i, attachment["type"], attachment["url"] + ) + ) + elif attachment["content_length"]: + click.echo( + "{}. **{}**: `<{} bytes>`".format( + i, + attachment["type"], + f"{attachment['content_length']:,}", + ) + ) + + # If a schema was provided and the row is valid JSON, pretty print and syntax highlight it + response = row["response"] + if row["schema_json"]: + try: + parsed = json.loads(response) + response = "```json\n{}\n```".format(json.dumps(parsed, indent=2)) + except ValueError: + pass + if row.get("reasoning"): + click.echo("\n## Reasoning\n\n{}".format(row["reasoning"].rstrip())) + click.echo("\n## Response\n") + if row["tool_calls"]: + click.echo("### Tool calls\n") + for tool_call in row["tool_calls"]: + click.echo( + "- **{}**: `{}`
\n{}".format( + tool_call["name"], + tool_call["tool_call_id"], + _format_tool_call_arguments(tool_call["arguments"]), + ) + ) + click.echo("") + if response: + click.echo("{}\n".format(response)) + if usage: + token_usage = _token_usage_markdown( + row["input_tokens"], + row["output_tokens"], + json.loads(row["token_details"]) if row["token_details"] else None, + ) + if token_usage: + click.echo("## 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", +} + + +def model_matches_id_or_alias(model_with_aliases, model_ids): + ids_and_aliases = set( + [model_with_aliases.model.model_id] + model_with_aliases.aliases + ) + return ids_and_aliases.intersection(model_ids) + + +def render_model_with_aliases( + model_with_aliases, + *, + options=False, + async_=False, + models_that_have_shown_options=None, +): + extra_info = [] + if model_with_aliases.aliases: + extra_info.append("aliases: {}".format(", ".join(model_with_aliases.aliases))) + model = model_with_aliases.model if not async_ else model_with_aliases.async_model + output = str(model) + if extra_info: + output += " ({})".format(", ".join(extra_info)) + if options and model.Options.model_json_schema()["properties"]: + output += "\n Options:" + for name, field in model.Options.model_json_schema()["properties"].items(): + any_of = field.get("anyOf") + if any_of is None: + any_of = [{"type": field.get("type", "str")}] + types = ", ".join( + [ + _type_lookup.get(item.get("type"), item.get("type", "str")) + for item in any_of + if item.get("type") != "null" + ] + ) + bits = ["\n ", name, ": ", types] + description = field.get("description", "") + if ( + description + and models_that_have_shown_options is not None + and model.__class__ not in models_that_have_shown_options + ): + wrapped = textwrap.wrap(description, 70) + bits.append("\n ") + bits.extend("\n ".join(wrapped)) + output += "".join(bits) + if models_that_have_shown_options is not None: + models_that_have_shown_options.add(model.__class__) + if options and model.attachment_types: + attachment_types = ", ".join(sorted(model.attachment_types)) + wrapper = textwrap.TextWrapper( + width=min(max(shutil.get_terminal_size().columns, 30), 70), + initial_indent=" ", + subsequent_indent=" ", + ) + output += "\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) + return output + + +def render_model_with_options(model_id, *, async_=False): + for model_with_aliases in get_models_with_aliases(): + if model_matches_id_or_alias(model_with_aliases, [model_id]): + return render_model_with_aliases( + model_with_aliases, + options=True, + async_=async_, + models_that_have_shown_options=set(), + ) + raise click.ClickException("'{}' is not a known model".format(model_id)) + + +@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: + if not model_matches_id_or_alias(model_with_aliases, model_ids): + continue + if schemas and not model_with_aliases.model.supports_schema: + continue + if tools and not model_with_aliases.model.supports_tools: + continue + click.echo( + render_model_with_aliases( + model_with_aliases, + options=options, + async_=async_, + models_that_have_shown_options=models_that_have_shown_options, + ) + ) + if not query and not options and not schemas and not model_ids: + click.echo(f"Default: {get_default_model()}") + + +@models.command(name="default") +@click.argument("model", required=False) +def models_default(model): + "Show or set the default model" + if not model: + click.echo(get_default_model()) + return + # Validate it is a known model + try: + model = get_model(model) + set_default_model(model.model_id) + except KeyError: + raise click.ClickException("Unknown model: {}".format(model)) + + +@cli.group( + cls=DefaultGroup, + default="list", + default_if_no_args=True, +) +def templates(): + "Manage stored prompt templates" + + +@templates.command(name="list") +def templates_list(): + "List available prompt templates" + path = template_dir() + pairs = [] + for file in path.glob("*.yaml"): + name = file.stem + try: + template = load_template(name) + except LoadTemplateError: + # Skip invalid templates + continue + text = [] + if template.system: + text.append(f"system: {template.system}") + if template.prompt: + text.append(f" prompt: {template.prompt}") + else: + text = [template.prompt if template.prompt else ""] + pairs.append((name, "".join(text).replace("\n", " "))) + try: + max_name_len = max(len(p[0]) for p in pairs) + except ValueError: + return + else: + fmt = "{name:<" + str(max_name_len) + "} : {prompt}" + for name, prompt in sorted(pairs): + text = fmt.format(name=name, prompt=prompt) + click.echo(display_truncated(text)) + + +@templates.command(name="show") +@click.argument("name") +def templates_show(name): + "Show the specified prompt template" + try: + template = load_template(name) + except LoadTemplateError: + raise click.ClickException(f"Template '{name}' not found or invalid") + click.echo( + yaml.dump( + dict((k, v) for k, v in template.model_dump().items() if v is not None), + indent=4, + default_flow_style=False, + ) + ) + + +@templates.command(name="edit") +@click.argument("name") +def templates_edit(name): + "Edit the specified prompt template using the default $EDITOR" + # First ensure it exists + path = template_dir() / f"{name}.yaml" + if not path.exists(): + path.write_text(DEFAULT_TEMPLATE, "utf-8") + click.edit(filename=str(path)) + # Validate that template + load_template(name) + + +@templates.command(name="path") +def templates_path(): + "Output the path to the templates directory" + click.echo(template_dir()) + + +@templates.command(name="loaders") +def templates_loaders(): + "Show template loaders registered by plugins" + found = False + for prefix, loader in get_template_loaders().items(): + found = True + docs = "Undocumented" + if loader.__doc__: + docs = textwrap.dedent(loader.__doc__).strip() + click.echo(f"{prefix}:") + click.echo(textwrap.indent(docs, " ")) + if not found: + click.echo("No template loaders found") + + +@cli.group( + cls=DefaultGroup, + default="list", + default_if_no_args=True, +) +def schemas(): + "Manage stored schemas" + + +@schemas.command(name="list") +@click.option( + "-p", + "--path", + type=click.Path(readable=True, exists=True, dir_okay=False), + help="Path to log database", + hidden=True, +) +@click.option( + "-d", + "--database", + type=click.Path(readable=True, exists=True, dir_okay=False), + help="Path to log database", +) +@click.option( + "queries", + "-q", + "--query", + multiple=True, + help="Search for schemas matching this string", +) +@click.option("--full", is_flag=True, help="Output full schema contents") +@click.option("json_", "--json", is_flag=True, help="Output as JSON") +@click.option("nl", "--nl", is_flag=True, help="Output as newline-delimited JSON") +def schemas_list(path, database, queries, full, json_, nl): + "List stored schemas" + if database and not path: + path = database + path = pathlib.Path(path or logs_db_path()) + if not path.exists(): + raise click.ClickException("No log database found at {}".format(path)) + db = sqlite_utils.Database(path) + migrate(db) + + params = [] + where_sql = "" + if queries: + where_bits = ["schemas.content like ?" for _ in queries] + where_sql += " where {}".format(" and ".join(where_bits)) + params.extend("%{}%".format(q) for q in queries) + + sql = """ + select + schemas.id, + schemas.content, + max(responses.datetime_utc) as recently_used, + count(*) as times_used + from schemas + join responses + on responses.schema_id = schemas.id + {} group by responses.schema_id + order by recently_used + """.format(where_sql) + rows = db.query(sql, params) + + if json_ or nl: + for line in output_rows_as_json(rows, json_cols={"content"}, nl=nl): + click.echo(line) + return + + for row in rows: + click.echo("- id: {}".format(row["id"])) + if full: + click.echo( + " schema: |\n{}".format( + textwrap.indent( + json.dumps(json.loads(row["content"]), indent=2), " " + ) + ) + ) + else: + click.echo( + " summary: |\n {}".format( + schema_summary(json.loads(row["content"])) + ) + ) + click.echo( + " usage: |\n {} time{}, most recently {}".format( + row["times_used"], + "s" if row["times_used"] != 1 else "", + row["recently_used"], + ) + ) + + +@schemas.command(name="show") +@click.argument("schema_id") +@click.option( + "-p", + "--path", + type=click.Path(readable=True, exists=True, dir_okay=False), + help="Path to log database", + hidden=True, +) +@click.option( + "-d", + "--database", + type=click.Path(readable=True, exists=True, dir_okay=False), + help="Path to log database", +) +def schemas_show(schema_id, path, database): + "Show a stored schema" + if database and not path: + path = database + path = pathlib.Path(path or logs_db_path()) + if not path.exists(): + raise click.ClickException("No log database found at {}".format(path)) + db = sqlite_utils.Database(path) + migrate(db) + + try: + row = db["schemas"].get(schema_id) + except sqlite_utils.db.NotFoundError: + raise click.ClickException("Invalid schema ID") + click.echo(json.dumps(json.loads(row["content"]), indent=2)) + + +@schemas.command(name="dsl") +@click.argument("input") +@click.option("--multi", is_flag=True, help="Wrap in an array") +def schemas_dsl_debug(input, multi): + """ + Convert LLM's schema DSL to a JSON schema + + \b + llm schema dsl 'name, age int, bio: their bio' + """ + schema = schema_dsl(input, multi) + click.echo(json.dumps(schema, indent=2)) + + +@cli.group( + cls=DefaultGroup, + default="list", + default_if_no_args=True, +) +def tools(): + "Manage tools that can be made available to LLMs" + + +@tools.command(name="list") +@click.argument("tool_defs", nargs=-1) +@click.option("json_", "--json", is_flag=True, help="Output as JSON") +@click.option( + "python_tools", + "--functions", + help="Python code block or file path defining functions to register as tools", + multiple=True, +) +def tools_list(tool_defs, json_, python_tools): + "List available tools that have been provided by plugins" + + def introspect_tools(toolbox_class): + methods = [] + for tool in toolbox_class.method_tools(): + methods.append( + { + "name": tool.name, + "description": tool.description, + "arguments": tool.input_schema, + "implementation": tool.implementation, + } + ) + return methods + + if tool_defs: + tools = {} + for tool in _gather_tools(tool_defs, python_tools): + if hasattr(tool, "name"): + tools[tool.name] = tool + else: + tools[tool.__class__.__name__] = tool + else: + tools = get_tools() + if python_tools: + for code_or_path in python_tools: + for tool in _tools_from_code(code_or_path): + tools[tool.name] = tool + + output_tools = [] + output_toolboxes = [] + tool_objects = [] + toolbox_objects = [] + for name, tool in sorted(tools.items()): + if isinstance(tool, Tool): + tool_objects.append(tool) + output_tools.append( + { + "name": name, + "description": tool.description, + "arguments": tool.input_schema, + "plugin": tool.plugin, + } + ) + else: + toolbox_objects.append(tool) + output_toolboxes.append( + { + "name": name, + "tools": [ + { + "name": tool["name"], + "description": tool["description"], + "arguments": tool["arguments"], + } + for tool in introspect_tools(tool) + ], + } + ) + if json_: + click.echo( + json.dumps( + {"tools": output_tools, "toolboxes": output_toolboxes}, + indent=2, + ) + ) + else: + for tool in tool_objects: + sig = "()" + if tool.implementation: + sig = str(inspect.signature(tool.implementation)) + click.echo( + "{}{}{}\n".format( + tool.name, + sig, + " (plugin: {})".format(tool.plugin) if tool.plugin else "", + ) + ) + if tool.description: + click.echo(textwrap.indent(tool.description.strip(), " ") + "\n") + for toolbox in toolbox_objects: + click.echo(toolbox.name + ":\n") + for tool in toolbox.method_tools(): + sig = ( + str(inspect.signature(tool.implementation)) + .replace("(self, ", "(") + .replace("(self)", "()") + ) + click.echo( + " {}{}\n".format( + tool.name, + sig, + ) + ) + if tool.description: + click.echo(textwrap.indent(tool.description.strip(), " ") + "\n") + + +@cli.group( + cls=DefaultGroup, + default="list", + default_if_no_args=True, +) +def aliases(): + "Manage model aliases" + + +@aliases.command(name="list") +@click.option("json_", "--json", is_flag=True, help="Output as JSON") +def aliases_list(json_): + "List current aliases" + to_output = [] + for alias, model in get_model_aliases().items(): + if alias != model.model_id: + to_output.append((alias, model.model_id, "")) + for alias, embedding_model in get_embedding_model_aliases().items(): + if alias != embedding_model.model_id: + to_output.append((alias, embedding_model.model_id, "embedding")) + if json_: + click.echo( + json.dumps({key: value for key, value, type_ in to_output}, indent=4) + ) + return + max_alias_length = max(len(a) for a, _, _ in to_output) + fmt = "{alias:<" + str(max_alias_length) + "} : {model_id}{type_}" + for alias, model_id, type_ in to_output: + click.echo( + fmt.format( + alias=alias, model_id=model_id, type_=f" ({type_})" if type_ else "" + ) + ) + + +@aliases.command(name="set") +@click.argument("alias") +@click.argument("model_id", required=False) +@click.option( + "-q", + "--query", + multiple=True, + help="Set alias for model matching these strings", +) +def aliases_set(alias, model_id, query): + """ + Set an alias for a model + + Example usage: + + \b + llm aliases set mini gpt-4o-mini + + Alternatively you can omit the model ID and specify one or more -q options. + The first model matching all of those query strings will be used. + + \b + llm aliases set mini -q 4o -q mini + """ + if not model_id: + if not query: + raise click.ClickException( + "You must provide a model_id or at least one -q option" + ) + # Search for the first model matching all query strings + found = None + for model_with_aliases in get_models_with_aliases(): + if all(model_with_aliases.matches(q) for q in query): + found = model_with_aliases + break + if not found: + raise click.ClickException( + "No model found matching query: " + ", ".join(query) + ) + model_id = found.model.model_id + set_alias(alias, model_id) + click.echo( + f"Alias '{alias}' set to model '{model_id}'", + err=True, + ) + else: + set_alias(alias, model_id) + + +@aliases.command(name="remove") +@click.argument("alias") +def aliases_remove(alias): + """ + Remove an alias + + Example usage: + + \b + $ llm aliases remove turbo + """ + try: + remove_alias(alias) + except KeyError as ex: + raise click.ClickException(ex.args[0]) + + +@aliases.command(name="path") +def aliases_path(): + "Output the path to the aliases.json file" + click.echo(user_dir() / "aliases.json") + + +@cli.group( + cls=DefaultGroup, + default="list", + default_if_no_args=True, +) +def fragments(): + """ + Manage fragments that are stored in the database + + Fragments are reusable snippets of text that are shared across multiple prompts. + """ + + +@fragments.command(name="list") +@click.option( + "queries", + "-q", + "--query", + multiple=True, + help="Search for fragments matching these strings", +) +@click.option("--aliases", is_flag=True, help="Show only fragments with aliases") +@click.option("json_", "--json", is_flag=True, help="Output as JSON") +def fragments_list(queries, aliases, json_): + "List current fragments" + db = sqlite_utils.Database(logs_db_path()) + migrate(db) + params = {} + param_count = 0 + where_bits = [] + if aliases: + where_bits.append("fragment_aliases.alias is not null") + for q in queries: + param_count += 1 + p = f"p{param_count}" + params[p] = q + where_bits.append(f""" + (fragments.hash = :{p} or fragment_aliases.alias = :{p} + or fragments.source like '%' || :{p} || '%' + or fragments.content like '%' || :{p} || '%') + """) + where = "\n and\n ".join(where_bits) + if where: + where = " where " + where + sql = """ + select + fragments.hash, + json_group_array(fragment_aliases.alias) filter ( + where + fragment_aliases.alias is not null + ) as aliases, + fragments.datetime_utc, + fragments.source, + fragments.content + from + fragments + left join + fragment_aliases on fragment_aliases.fragment_id = fragments.id + {where} + group by + fragments.id, fragments.hash, fragments.content, fragments.datetime_utc, fragments.source + order by fragments.datetime_utc + """.format(where=where) + results = list(db.query(sql, params)) + for result in results: + result["aliases"] = json.loads(result["aliases"]) + if json_: + click.echo(json.dumps(results, indent=4)) + else: + yaml.add_representer( + str, + lambda dumper, data: dumper.represent_scalar( + "tag:yaml.org,2002:str", data, style="|" if "\n" in data else None + ), + ) + for result in results: + result["content"] = truncate_string(result["content"]) + click.echo(yaml.dump([result], sort_keys=False, width=sys.maxsize).strip()) + + +@fragments.command(name="set") +@click.argument("alias", callback=validate_fragment_alias) +@click.argument("fragment") +def fragments_set(alias, fragment): + """ + Set an alias for a fragment + + Accepts an alias and a file path, URL, hash or '-' for stdin + + Example usage: + + \b + llm fragments set mydocs ./docs.md + """ + db = sqlite_utils.Database(logs_db_path()) + migrate(db) + try: + resolved = resolve_fragments(db, [fragment])[0] + except FragmentNotFound as ex: + raise click.ClickException(str(ex)) + migrate(db) + alias_sql = """ + insert into fragment_aliases (alias, fragment_id) + values (:alias, :fragment_id) + on conflict(alias) do update set + fragment_id = excluded.fragment_id; + """ + with db.conn: + fragment_id = ensure_fragment(db, resolved) + db.conn.execute(alias_sql, {"alias": alias, "fragment_id": fragment_id}) + + +@fragments.command(name="show") +@click.argument("alias_or_hash") +def fragments_show(alias_or_hash): + """ + Display the fragment stored under an alias or hash + + \b + llm fragments show mydocs + """ + db = sqlite_utils.Database(logs_db_path()) + migrate(db) + try: + resolved = resolve_fragments(db, [alias_or_hash])[0] + except FragmentNotFound as ex: + raise click.ClickException(str(ex)) + click.echo(resolved) + + +@fragments.command(name="remove") +@click.argument("alias", callback=validate_fragment_alias) +def fragments_remove(alias): + """ + Remove a fragment alias + + Example usage: + + \b + llm fragments remove docs + """ + db = sqlite_utils.Database(logs_db_path()) + migrate(db) + with db.conn: + db.conn.execute( + "delete from fragment_aliases where alias = :alias", {"alias": alias} + ) + + +@fragments.command(name="loaders") +def fragments_loaders(): + """Show fragment loaders registered by plugins""" + from llm import get_fragment_loaders + + found = False + for prefix, loader in get_fragment_loaders().items(): + if found: + # Extra newline on all after the first + click.echo("") + found = True + docs = "Undocumented" + if loader.__doc__: + docs = textwrap.dedent(loader.__doc__).strip() + click.echo(f"{prefix}:") + click.echo(textwrap.indent(docs, " ")) + if not found: + click.echo("No fragment loaders found") + + +@cli.command(name="plugins") +@click.option("--all", help="Include built-in default plugins", is_flag=True) +@click.option( + "hooks", "--hook", help="Filter for plugins that implement this hook", multiple=True +) +def plugins_list(all, hooks): + "List installed plugins" + plugins = get_plugins(all) + hooks = set(hooks) + if hooks: + plugins = [plugin for plugin in plugins if hooks.intersection(plugin["hooks"])] + click.echo(json.dumps(plugins, indent=2)) + + +def display_truncated(text): + console_width = shutil.get_terminal_size()[0] + if len(text) > console_width: + return text[: console_width - 3] + "..." + else: + return text + + +@cli.command() +@click.argument("packages", nargs=-1, required=False) +@click.option( + "-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version" +) +@click.option( + "-e", + "--editable", + help="Install a project in editable mode from this path", +) +@click.option( + "--force-reinstall", + is_flag=True, + help="Reinstall all packages even if they are already up-to-date", +) +@click.option( + "--no-cache-dir", + is_flag=True, + help="Disable the cache", +) +@click.option( + "--pre", + is_flag=True, + help="Include pre-release and development versions", +) +def install(packages, upgrade, editable, force_reinstall, no_cache_dir, pre): + """Install packages from PyPI into the same environment as LLM""" + args = ["pip", "install"] + if upgrade: + args += ["--upgrade"] + if editable: + args += ["--editable", editable] + if force_reinstall: + args += ["--force-reinstall"] + if no_cache_dir: + args += ["--no-cache-dir"] + if pre: + args += ["--pre"] + args += list(packages) + sys.argv = args + run_module("pip", run_name="__main__") + + +@cli.command() +@click.argument("packages", nargs=-1, required=True) +@click.option("-y", "--yes", is_flag=True, help="Don't ask for confirmation") +def uninstall(packages, yes): + """Uninstall Python packages from the LLM environment""" + sys.argv = ["pip", "uninstall"] + list(packages) + (["-y"] if yes else []) + run_module("pip", run_name="__main__") + + +@cli.command() +@click.argument("collection", required=False) +@click.argument("id", required=False) +@click.option( + "-i", + "--input", + type=click.Path(exists=True, readable=True, allow_dash=True), + help="File to embed", +) +@click.option( + "-m", "--model", help="Embedding model to use", envvar="LLM_EMBEDDING_MODEL" +) +@click.option("--store", is_flag=True, help="Store the text itself in the database") +@click.option( + "-d", + "--database", + type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True), + envvar="LLM_EMBEDDINGS_DB", +) +@click.option( + "-c", + "--content", + help="Content to embed", +) +@click.option("--binary", is_flag=True, help="Treat input as binary data") +@click.option( + "--metadata", + help="JSON object metadata to store", + callback=json_validator("metadata"), +) +@click.option( + "format_", + "-f", + "--format", + type=click.Choice(["json", "blob", "base64", "hex"]), + help="Output format", +) +def embed( + collection, id, input, model, store, database, content, binary, metadata, format_ +): + """Embed text and store or return the result""" + if collection and not id: + raise click.ClickException("Must provide both collection and id") + + if store and not collection: + raise click.ClickException("Must provide collection when using --store") + + # Lazy load this because we do not need it for -c or -i versions + def get_db(): + if database: + return sqlite_utils.Database(database) + else: + return sqlite_utils.Database(user_dir() / "embeddings.db") + + collection_obj = None + model_obj = None + if collection: + db = get_db() + if Collection.exists(db, collection): + # Load existing collection and use its model + collection_obj = Collection(collection, db) + model_obj = collection_obj.model() + else: + # We will create a new one, but that means model is required + if not model: + model = get_default_embedding_model() + if model is None: + raise click.ClickException( + "You need to specify an embedding model (no default model is set)" + ) + collection_obj = Collection(collection, db=db, model_id=model) + model_obj = collection_obj.model() + + if model_obj is None: + if model is None: + model = get_default_embedding_model() + try: + model_obj = get_embedding_model(model) + except UnknownModelError: + raise click.ClickException( + "You need to specify an embedding model (no default model is set)" + ) + + show_output = True + if collection and (format_ is None): + show_output = False + + # Resolve input text + if not content: + if not input or input == "-": + # Read from stdin + input_source = sys.stdin.buffer if binary else sys.stdin + content = input_source.read() + else: + mode = "rb" if binary else "r" + with open(input, mode) as f: + content = f.read() + + if not content: + raise click.ClickException("No content provided") + + if collection_obj: + embedding = collection_obj.embed(id, content, metadata=metadata, store=store) + else: + embedding = model_obj.embed(content) + + if show_output: + if format_ == "json" or format_ is None: + click.echo(json.dumps(embedding)) + elif format_ == "blob": + click.echo(encode(embedding)) + elif format_ == "base64": + click.echo(base64.b64encode(encode(embedding)).decode("ascii")) + elif format_ == "hex": + click.echo(encode(embedding).hex()) + + +@cli.command() +@click.argument("collection") +@click.argument( + "input_path", + type=click.Path(exists=True, dir_okay=False, allow_dash=True, readable=True), + required=False, +) +@click.option( + "--format", + type=click.Choice(["json", "csv", "tsv", "nl"]), + help="Format of input file - defaults to auto-detect", +) +@click.option( + "--files", + type=(click.Path(file_okay=False, dir_okay=True, allow_dash=False), str), + multiple=True, + help="Embed files in this directory - specify directory and glob pattern", +) +@click.option( + "encodings", + "--encoding", + help="Encodings to try when reading --files", + multiple=True, +) +@click.option("--binary", is_flag=True, help="Treat --files as binary data") +@click.option("--sql", help="Read input using this SQL query") +@click.option( + "--attach", + type=(str, click.Path(file_okay=True, dir_okay=False, allow_dash=False)), + multiple=True, + help="Additional databases to attach - specify alias and file path", +) +@click.option( + "--batch-size", type=int, help="Batch size to use when running embeddings" +) +@click.option("--prefix", help="Prefix to add to the IDs", default="") +@click.option( + "-m", "--model", help="Embedding model to use", envvar="LLM_EMBEDDING_MODEL" +) +@click.option( + "--prepend", + help="Prepend this string to all content before embedding", +) +@click.option("--store", is_flag=True, help="Store the text itself in the database") +@click.option( + "-d", + "--database", + type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True), + envvar="LLM_EMBEDDINGS_DB", +) +def embed_multi( + collection, + input_path, + format, + files, + encodings, + binary, + sql, + attach, + batch_size, + prefix, + model, + prepend, + store, + database, +): + """ + Store embeddings for multiple strings at once in the specified collection. + + Input data can come from one of three sources: + + \b + 1. A CSV, TSV, JSON or JSONL file: + - CSV/TSV: First column is ID, remaining columns concatenated as content + - JSON: Array of objects with "id" field and content fields + - JSONL: Newline-delimited JSON objects + + \b + Examples: + llm embed-multi docs input.csv + cat data.json | llm embed-multi docs - + llm embed-multi docs input.json --format json + + \b + 2. A SQL query against a SQLite database: + - First column returned is used as ID + - Other columns concatenated to form content + + \b + Examples: + llm embed-multi docs --sql "SELECT id, title, body FROM posts" + llm embed-multi docs --attach blog blog.db --sql "SELECT id, content FROM blog.posts" + + \b + 3. Files in directories matching glob patterns: + - Each file becomes one embedding + - Relative file paths become IDs + + \b + Examples: + llm embed-multi docs --files docs '**/*.md' + llm embed-multi images --files photos '*.jpg' --binary + llm embed-multi texts --files texts '*.txt' --encoding utf-8 --encoding latin-1 + """ + if binary and not files: + raise click.UsageError("--binary must be used with --files") + if binary and encodings: + raise click.UsageError("--binary cannot be used with --encoding") + if not input_path and not sql and not files: + raise click.UsageError("Either --sql or input path or --files is required") + + if files: + if input_path or sql or format: + raise click.UsageError( + "Cannot use --files with --sql, input path or --format" + ) + + if database: + db = sqlite_utils.Database(database) + else: + db = sqlite_utils.Database(user_dir() / "embeddings.db") + + for alias, attach_path in attach: + db.attach(alias, attach_path) + + try: + collection_obj = Collection( + collection, db=db, model_id=model or get_default_embedding_model() + ) + except ValueError: + raise click.ClickException( + "You need to specify an embedding model (no default model is set)" + ) + + expected_length = None + if files: + encodings = encodings or ("utf-8", "latin-1") + + def count_files(): + i = 0 + for directory, pattern in files: + for path in pathlib.Path(directory).glob(pattern): + i += 1 + return i + + def iterate_files(): + for directory, pattern in files: + p = pathlib.Path(directory) + if not p.exists() or not p.is_dir(): + # fixes issue/274 - raise error if directory does not exist + raise click.UsageError(f"Invalid directory: {directory}") + for path in pathlib.Path(directory).glob(pattern): + if path.is_dir(): + continue # fixed issue/280 - skip directories + relative = path.relative_to(directory) + content = None + if binary: + content = path.read_bytes() + else: + for encoding in encodings: + try: + content = path.read_text(encoding=encoding) + except UnicodeDecodeError: + continue + if content is None: + # Log to stderr + click.echo( + "Could not decode text in file {}".format(path), + err=True, + ) + else: + yield {"id": str(relative), "content": content} + + expected_length = count_files() + rows = iterate_files() + elif sql: + rows = db.query(sql) + count_sql = "select count(*) as c from ({})".format(sql) + expected_length = next(db.query(count_sql))["c"] + else: + + def load_rows(fp): + return rows_from_file(fp, Format[format.upper()] if format else None)[0] + + try: + if input_path != "-": + # Read the file twice - first time is to get a count + expected_length = 0 + with open(input_path, "rb") as fp: + for _ in load_rows(fp): + expected_length += 1 + + rows = load_rows( + open(input_path, "rb") + if input_path != "-" + else io.BufferedReader(sys.stdin.buffer) + ) + except json.JSONDecodeError as ex: + raise click.ClickException(str(ex)) + + with click.progressbar( + rows, label="Embedding", show_percent=True, length=expected_length + ) as rows: + + def tuples() -> Iterable[Tuple[str, Union[bytes, str]]]: + for row in rows: + values = list(row.values()) + id: str = prefix + str(values[0]) + content: Optional[Union[bytes, str]] = None + if binary: + content = cast(bytes, values[1]) + else: + content = " ".join(v or "" for v in values[1:]) + if prepend and isinstance(content, str): + content = prepend + content + yield id, content or "" + + embed_kwargs = {"store": store} + if batch_size: + embed_kwargs["batch_size"] = batch_size + collection_obj.embed_multi(tuples(), **embed_kwargs) + + +@cli.command() +@click.argument("collection") +@click.argument("id", required=False) +@click.option( + "-i", + "--input", + type=click.Path(exists=True, readable=True, allow_dash=True), + help="File to embed for comparison", +) +@click.option("-c", "--content", help="Content to embed for comparison") +@click.option("--binary", is_flag=True, help="Treat input as binary data") +@click.option( + "-n", "--number", type=int, default=10, help="Number of results to return" +) +@click.option("-p", "--plain", is_flag=True, help="Output in plain text format") +@click.option( + "-d", + "--database", + type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True), + envvar="LLM_EMBEDDINGS_DB", +) +@click.option("--prefix", help="Just IDs with this prefix", default="") +def similar(collection, id, input, content, binary, number, plain, database, prefix): + """ + Return top N similar IDs from a collection using cosine similarity. + + Example usage: + + \b + llm similar my-collection -c "I like cats" + + Or to find content similar to a specific stored ID: + + \b + llm similar my-collection 1234 + """ + if not id and not content and not input: + raise click.ClickException("Must provide content or an ID for the comparison") + + if database: + db = sqlite_utils.Database(database) + else: + db = sqlite_utils.Database(user_dir() / "embeddings.db") + + if not db["embeddings"].exists(): + raise click.ClickException("No embeddings table found in database") + + try: + collection_obj = Collection(collection, db, create=False) + except Collection.DoesNotExist: + raise click.ClickException("Collection does not exist") + + if id: + try: + results = collection_obj.similar_by_id(id, number, prefix=prefix) + except Collection.DoesNotExist: + raise click.ClickException("ID not found in collection") + else: + # Resolve input text + if not content: + if not input or input == "-": + # Read from stdin + input_source = sys.stdin.buffer if binary else sys.stdin + content = input_source.read() + else: + mode = "rb" if binary else "r" + with open(input, mode) as f: + content = f.read() + if not content: + raise click.ClickException("No content provided") + results = collection_obj.similar(content, number, prefix=prefix) + + for result in results: + if plain: + click.echo(f"{result.id} ({result.score})\n") + if result.content: + click.echo(textwrap.indent(result.content, " ")) + if result.metadata: + click.echo(textwrap.indent(json.dumps(result.metadata), " ")) + click.echo("") + else: + click.echo(json.dumps(asdict(result))) + + +@cli.group( + cls=DefaultGroup, + default="list", + default_if_no_args=True, +) +def embed_models(): + "Manage available embedding models" + + +@embed_models.command(name="list") +@click.option( + "-q", + "--query", + multiple=True, + help="Search for embedding models matching these strings", +) +def embed_models_list(query): + "List available embedding models" + output = [] + for model_with_aliases in get_embedding_models_with_aliases(): + if query: + if not all(model_with_aliases.matches(q) for q in query): + continue + s = str(model_with_aliases.model) + if model_with_aliases.aliases: + s += " (aliases: {})".format(", ".join(model_with_aliases.aliases)) + output.append(s) + click.echo("\n".join(output)) + + +@embed_models.command(name="default") +@click.argument("model", required=False) +@click.option( + "--remove-default", is_flag=True, help="Reset to specifying no default model" +) +def embed_models_default(model, remove_default): + "Show or set the default embedding model" + if not model and not remove_default: + default = get_default_embedding_model() + if default is None: + click.echo("", err=True) + else: + click.echo(default) + return + # Validate it is a known model + try: + if remove_default: + set_default_embedding_model(None) + else: + model = get_embedding_model(model) + set_default_embedding_model(model.model_id) + except KeyError: + raise click.ClickException("Unknown embedding model: {}".format(model)) + + +@cli.group( + cls=DefaultGroup, + default="list", + default_if_no_args=True, +) +def collections(): + "View and manage collections of embeddings" + + +@collections.command(name="path") +def collections_path(): + "Output the path to the embeddings database" + click.echo(user_dir() / "embeddings.db") + + +@collections.command(name="list") +@click.option( + "-d", + "--database", + type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True), + envvar="LLM_EMBEDDINGS_DB", + help="Path to embeddings database", +) +@click.option("json_", "--json", is_flag=True, help="Output as JSON") +def embed_db_collections(database, json_): + "View a list of collections" + database = database or (user_dir() / "embeddings.db") + db = sqlite_utils.Database(str(database)) + if not db["collections"].exists(): + raise click.ClickException("No collections table found in {}".format(database)) + rows = db.query(""" + select + collections.name, + collections.model, + count(embeddings.id) as num_embeddings + from + collections left join embeddings + on collections.id = embeddings.collection_id + group by + collections.name, collections.model + """) + if json_: + click.echo(json.dumps(list(rows), indent=4)) + else: + for row in rows: + click.echo("{}: {}".format(row["name"], row["model"])) + click.echo( + " {} embedding{}".format( + row["num_embeddings"], "s" if row["num_embeddings"] != 1 else "" + ) + ) + + +@collections.command(name="delete") +@click.argument("collection") +@click.option( + "-d", + "--database", + type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True), + envvar="LLM_EMBEDDINGS_DB", + help="Path to embeddings database", +) +def collections_delete(collection, database): + """ + Delete the specified collection + + Example usage: + + \b + llm collections delete my-collection + """ + database = database or (user_dir() / "embeddings.db") + db = sqlite_utils.Database(str(database)) + try: + collection_obj = Collection(collection, db, create=False) + except Collection.DoesNotExist: + raise click.ClickException("Collection does not exist") + collection_obj.delete() + + +@models.group( + cls=DefaultGroup, + default="list", + default_if_no_args=True, +) +def options(): + "Manage default options for models" + + +@options.command(name="list") +def options_list(): + """ + List default options for all models + + Example usage: + + \b + llm models options list + """ + options = get_all_model_options() + if not options: + click.echo("No default options set for any models.", err=True) + return + + for model_id, model_options in options.items(): + click.echo(f"{model_id}:") + for key, value in model_options.items(): + click.echo(f" {key}: {value}") + + +@options.command(name="show") +@click.argument("model") +def options_show(model): + """ + List default options set for a specific model + + Example usage: + + \b + llm models options show gpt-4o + """ + import llm + + try: + # Resolve alias to model ID + model_obj = llm.get_model(model) + model_id = model_obj.model_id + except llm.UnknownModelError: + # Use as-is if not found + model_id = model + + options = get_model_options(model_id) + if not options: + click.echo(f"No default options set for model '{model_id}'.", err=True) + return + + for key, value in options.items(): + click.echo(f"{key}: {value}") + + +@options.command(name="set") +@click.argument("model") +@click.argument("key") +@click.argument("value") +def options_set(model, key, value): + """ + Set a default option for a model + + Example usage: + + \b + llm models options set gpt-4o temperature 0.5 + """ + import llm + + try: + # Resolve alias to model ID + model_obj = llm.get_model(model) + model_id = model_obj.model_id + + # Validate option against model schema + try: + # Create a test Options object to validate + test_options = {key: value} + model_obj.Options(**test_options) + except pydantic.ValidationError as ex: + raise click.ClickException(render_errors(ex.errors())) + + except llm.UnknownModelError: + # Use as-is if not found + model_id = model + + set_model_option(model_id, key, value) + click.echo(f"Set default option {key}={value} for model {model_id}", err=True) + + +@options.command(name="clear") +@click.argument("model") +@click.argument("key", required=False) +def options_clear(model, key): + """ + Clear default option(s) for a model + + Example usage: + + \b + llm models options clear gpt-4o + # Or for a single option + llm models options clear gpt-4o temperature + """ + import llm + + try: + # Resolve alias to model ID + model_obj = llm.get_model(model) + model_id = model_obj.model_id + except llm.UnknownModelError: + # Use as-is if not found + model_id = model + + cleared_keys = [] + if not key: + cleared_keys = list(get_model_options(model_id).keys()) + for key_ in cleared_keys: + clear_model_option(model_id, key_) + else: + cleared_keys.append(key) + clear_model_option(model_id, key) + if cleared_keys: + if len(cleared_keys) == 1: + click.echo(f"Cleared option '{cleared_keys[0]}' for model {model_id}") + else: + click.echo( + f"Cleared {', '.join(cleared_keys)} options for model {model_id}" + ) + + +def template_dir(): + path = user_dir() / "templates" + path.mkdir(parents=True, exist_ok=True) + return path + + +def logs_db_path(): + return user_dir() / "logs.db" + + +def get_history(chat_id): + if chat_id is None: + return None, [] + log_path = logs_db_path() + db = sqlite_utils.Database(log_path) + migrate(db) + if chat_id == -1: + # Return the most recent chat + last_row = list(db["logs"].rows_where(order_by="-id", limit=1)) + if last_row: + chat_id = last_row[0].get("chat_id") or last_row[0].get("id") + else: # Database is empty + return None, [] + rows = db["logs"].rows_where( + "id = ? or chat_id = ?", [chat_id, chat_id], order_by="id" + ) + return chat_id, rows + + +def render_errors(errors): + output = [] + for error in errors: + output.append(", ".join(error["loc"])) + output.append(" " + error["msg"]) + return "\n".join(output) + + +load_plugins() + +pm.hook.register_commands(cli=cli) + + +def _human_readable_size(size_bytes): + if size_bytes == 0: + return "0B" + + size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + i = 0 + + while size_bytes >= 1024 and i < len(size_name) - 1: + size_bytes /= 1024.0 + i += 1 + + return "{:.2f}{}".format(size_bytes, size_name[i]) + + +def logs_on(): + return not (user_dir() / "logs-off").exists() + + +def get_all_model_options() -> dict: + """ + Get all default options for all models + """ + path = user_dir() / "model_options.json" + if not path.exists(): + return {} + + try: + options = json.loads(path.read_text()) + except json.JSONDecodeError: + return {} + + return options + + +def get_model_options(model_id: str) -> dict: + """ + Get default options for a specific model + + Args: + model_id: Return options for model with this ID + + Returns: + A dictionary of model options + """ + path = user_dir() / "model_options.json" + if not path.exists(): + return {} + + try: + options = json.loads(path.read_text()) + except json.JSONDecodeError: + return {} + + return options.get(model_id, {}) + + +def set_model_option(model_id: str, key: str, value: Any) -> None: + """ + Set a default option for a model. + + Args: + model_id: The model ID + key: The option key + value: The option value + """ + path = user_dir() / "model_options.json" + if path.exists(): + try: + options = json.loads(path.read_text()) + except json.JSONDecodeError: + options = {} + else: + options = {} + + # Ensure the model has an entry + if model_id not in options: + options[model_id] = {} + + # Set the option + options[model_id][key] = value + + # Save the options + path.write_text(json.dumps(options, indent=2)) + + +def clear_model_option(model_id: str, key: str) -> None: + """ + Clear a model option + + Args: + model_id: The model ID + key: Key to clear + """ + path = user_dir() / "model_options.json" + if not path.exists(): + return + + try: + options = json.loads(path.read_text()) + except json.JSONDecodeError: + return + + if model_id not in options: + return + + if key in options[model_id]: + del options[model_id][key] + if not options[model_id]: + del options[model_id] + + path.write_text(json.dumps(options, indent=2)) + + +class LoadTemplateError(ValueError): + pass + + +def _parse_yaml_template(name, content): + try: + loaded = yaml.safe_load(content) + except yaml.YAMLError as ex: + raise LoadTemplateError("Invalid YAML: {}".format(str(ex))) + if isinstance(loaded, str): + return Template(name=name, prompt=loaded) + loaded["name"] = name + try: + return Template(**loaded) + except pydantic.ValidationError as ex: + msg = "A validation error occurred:\n" + msg += render_errors(ex.errors()) + raise LoadTemplateError(msg) + + +def load_template(name: str) -> Template: + "Load template, or raise LoadTemplateError(msg)" + if name.startswith("https://") or name.startswith("http://"): + response = httpx.get(name) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + raise LoadTemplateError("Could not load template {}: {}".format(name, ex)) + return _parse_yaml_template(name, response.text) + + potential_path = pathlib.Path(name) + + if has_plugin_prefix(name) and not potential_path.exists(): + prefix, rest = name.split(":", 1) + loaders = get_template_loaders() + if prefix not in loaders: + raise LoadTemplateError("Unknown template prefix: {}".format(prefix)) + loader = loaders[prefix] + try: + return loader(rest) + except Exception as ex: + raise LoadTemplateError("Could not load template {}: {}".format(name, ex)) + + # Try local file + if potential_path.exists(): + path = potential_path + else: + # Look for template in template_dir() + path = template_dir() / f"{name}.yaml" + if not path.exists(): + raise LoadTemplateError(f"Invalid template: {name}") + content = path.read_text() + template_obj = _parse_yaml_template(name, content) + # We trust functions here because they came from the filesystem + template_obj._functions_is_trusted = True + return template_obj + + +def _tools_from_code(code_or_path: str) -> List[Tool]: + """ + Treat all Python functions in the code as tools + """ + if "\n" not in code_or_path and code_or_path.endswith(".py"): + try: + code_or_path = pathlib.Path(code_or_path).read_text() + except FileNotFoundError: + raise click.ClickException("File not found: {}".format(code_or_path)) + namespace: Dict[str, Any] = {} + tools = [] + try: + exec(code_or_path, namespace) + except SyntaxError as ex: + raise click.ClickException("Error in --functions definition: {}".format(ex)) + # Register all callables in the locals dict: + for name, value in namespace.items(): + if callable(value) and not name.startswith("_"): + tools.append(Tool.function(value)) + return tools + + +def _debug_tool_call(_, tool_call, tool_result): + click.echo( + click.style( + "\nTool call: {}({})".format(tool_call.name, tool_call.arguments), + fg="yellow", + bold=True, + ), + err=True, + ) + output = "" + attachments = "" + if tool_result.attachments: + attachments += "\nAttachments:\n" + for attachment in tool_result.attachments: + attachments += f" {repr(attachment)}\n" + + try: + output = json.dumps(json.loads(tool_result.output), indent=2) + except ValueError: + output = tool_result.output + output += attachments + click.echo( + click.style( + textwrap.indent(output, " ") + ("\n" if not tool_result.exception else ""), + fg="green", + bold=True, + ), + err=True, + ) + if tool_result.exception: + click.echo( + click.style( + " Exception: {}".format(tool_result.exception), + fg="red", + bold=True, + ), + err=True, + ) + + +def _approve_tool_call(_, tool_call): + click.echo( + click.style( + "Tool call: {}({})".format(tool_call.name, tool_call.arguments), + fg="yellow", + bold=True, + ), + err=True, + ) + if not click.confirm("Approve tool call?"): + raise CancelToolCall("User cancelled tool call") + + +def _gather_tools( + tool_specs: List[str], python_tools: List[str] +) -> List[Union[Tool, Type[Toolbox]]]: + tools: List[Union[Tool, Type[Toolbox]]] = [] + if python_tools: + for code_or_path in python_tools: + tools.extend(_tools_from_code(code_or_path)) + registered_tools = get_tools() + registered_classes = dict( + (key, value) + for key, value in registered_tools.items() + if inspect.isclass(value) + ) + bad_tools = [ + tool for tool in tool_specs if tool.split("(")[0] not in registered_tools + ] + if bad_tools: + raise click.ClickException( + "Tool(s) {} not found. Available tools: {}".format( + ", ".join(bad_tools), ", ".join(registered_tools.keys()) + ) + ) + for tool_spec in tool_specs: + if not tool_spec[0].isupper(): + # It's a function + tools.append(registered_tools[tool_spec]) + else: + # It's a class + tools.append(instantiate_from_spec(registered_classes, tool_spec)) + return tools + + +def _get_conversation_tools(conversation, tools): + if conversation and not tools and conversation.responses: + # Copy plugin tools from first response in conversation + initial_tools = conversation.responses[0].prompt.tools + if initial_tools: + # Only tools from plugins: + return [tool.name for tool in initial_tools if tool.plugin] diff --git a/llm/default_plugins/__init__.py b/llm/default_plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/llm/default_plugins/default_tools.py b/llm/default_plugins/default_tools.py new file mode 100644 index 0000000..53ff72c --- /dev/null +++ b/llm/default_plugins/default_tools.py @@ -0,0 +1,8 @@ +import llm +from llm.tools import llm_time, llm_version + + +@llm.hookimpl +def register_tools(register): + register(llm_version) + register(llm_time) diff --git a/llm/default_plugins/openai_models.py b/llm/default_plugins/openai_models.py new file mode 100644 index 0000000..45c3fc0 --- /dev/null +++ b/llm/default_plugins/openai_models.py @@ -0,0 +1,2052 @@ +from llm import ( + AsyncConversation, + AsyncKeyModel, + AsyncResponse, + Conversation, + EmbeddingModel, + KeyModel, + Prompt, + Response, + hookimpl, +) +import llm +from llm.parts import StreamEvent +from llm.utils import ( + dicts_to_table_string, + remove_dict_none_values, + logging_client, + simplify_usage_dict, +) +import click +import datetime +from enum import Enum +import httpx +import openai +import os + +from pydantic import create_model, field_validator, Field + +from typing import ( + Any, + AsyncGenerator, + cast, + Dict, + List, + Iterable, + Iterator, + Optional, + Union, +) +import json +import yaml + + +@hookimpl +def register_models(register): + # GPT-4o + register( + Chat("gpt-4o", vision=True, supports_schema=True, supports_tools=True), + AsyncChat("gpt-4o", vision=True, supports_schema=True, supports_tools=True), + aliases=("4o",), + ) + register( + Chat("chatgpt-4o-latest", vision=True), + AsyncChat("chatgpt-4o-latest", vision=True), + aliases=("chatgpt-4o",), + ) + register( + Chat("gpt-4o-mini", vision=True, supports_schema=True, supports_tools=True), + AsyncChat( + "gpt-4o-mini", vision=True, supports_schema=True, supports_tools=True + ), + aliases=("4o-mini",), + ) + for audio_model_id in ( + "gpt-4o-audio-preview", + "gpt-4o-audio-preview-2024-12-17", + "gpt-4o-audio-preview-2024-10-01", + "gpt-4o-mini-audio-preview", + "gpt-4o-mini-audio-preview-2024-12-17", + ): + register( + Chat(audio_model_id, audio=True), + AsyncChat(audio_model_id, audio=True), + ) + # GPT-4.1 + for model_id in ("gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"): + register( + Chat(model_id, vision=True, supports_schema=True, supports_tools=True), + AsyncChat(model_id, vision=True, supports_schema=True, supports_tools=True), + aliases=(model_id.replace("gpt-", ""),), + ) + # 3.5 and 4 + register( + Chat("gpt-3.5-turbo"), AsyncChat("gpt-3.5-turbo"), aliases=("3.5", "chatgpt") + ) + register( + Chat("gpt-3.5-turbo-16k"), + AsyncChat("gpt-3.5-turbo-16k"), + aliases=("chatgpt-16k", "3.5-16k"), + ) + register(Chat("gpt-4"), AsyncChat("gpt-4"), aliases=("4", "gpt4")) + register(Chat("gpt-4-32k"), AsyncChat("gpt-4-32k"), aliases=("4-32k",)) + # GPT-4 Turbo models + register(Chat("gpt-4-1106-preview"), AsyncChat("gpt-4-1106-preview")) + register(Chat("gpt-4-0125-preview"), AsyncChat("gpt-4-0125-preview")) + register(Chat("gpt-4-turbo-2024-04-09"), AsyncChat("gpt-4-turbo-2024-04-09")) + register( + Chat("gpt-4-turbo"), + AsyncChat("gpt-4-turbo"), + aliases=("gpt-4-turbo-preview", "4-turbo", "4t"), + ) + # GPT-4.5 + register( + Chat( + "gpt-4.5-preview-2025-02-27", + vision=True, + supports_schema=True, + supports_tools=True, + ), + AsyncChat( + "gpt-4.5-preview-2025-02-27", + vision=True, + supports_schema=True, + supports_tools=True, + ), + ) + register( + Chat("gpt-4.5-preview", vision=True, supports_schema=True, supports_tools=True), + AsyncChat( + "gpt-4.5-preview", vision=True, supports_schema=True, supports_tools=True + ), + aliases=("gpt-4.5",), + ) + # o1 + for model_id in ("o1", "o1-2024-12-17"): + register( + Responses( + model_id, + vision=True, + can_stream=False, + reasoning=True, + supports_schema=True, + supports_tools=True, + ), + AsyncResponses( + model_id, + vision=True, + can_stream=False, + reasoning=True, + supports_schema=True, + supports_tools=True, + ), + ) + + register( + Chat("o1-preview", allows_system_prompt=False), + AsyncChat("o1-preview", allows_system_prompt=False), + ) + register( + Chat("o1-mini", allows_system_prompt=False), + AsyncChat("o1-mini", allows_system_prompt=False), + ) + register( + Responses("o3-mini", reasoning=True, supports_schema=True, supports_tools=True), + AsyncResponses( + "o3-mini", reasoning=True, supports_schema=True, supports_tools=True + ), + ) + register( + Responses( + "o3", vision=True, reasoning=True, supports_schema=True, supports_tools=True + ), + AsyncResponses( + "o3", vision=True, reasoning=True, supports_schema=True, supports_tools=True + ), + ) + register( + Responses( + "o4-mini", + vision=True, + reasoning=True, + supports_schema=True, + supports_tools=True, + ), + AsyncResponses( + "o4-mini", + vision=True, + reasoning=True, + supports_schema=True, + supports_tools=True, + ), + ) + # GPT-5 + for model_id in ( + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-5-2025-08-07", + "gpt-5-mini-2025-08-07", + "gpt-5-nano-2025-08-07", + ): + register( + Responses( + model_id, + vision=True, + reasoning=True, + verbosity=True, + supports_schema=True, + supports_tools=True, + ), + AsyncResponses( + model_id, + vision=True, + reasoning=True, + verbosity=True, + supports_schema=True, + supports_tools=True, + ), + ) + # GPT-5.1 + for model_id in ( + "gpt-5.1", + "gpt-5.1-chat-latest", + ): + register( + Responses( + model_id, + vision=True, + reasoning=True, + verbosity=True, + supports_schema=True, + supports_tools=True, + ), + AsyncResponses( + model_id, + vision=True, + reasoning=True, + verbosity=True, + supports_schema=True, + supports_tools=True, + ), + ) + # GPT-5.2 + for model_id in ("gpt-5.2", "gpt-5.2-chat-latest"): + register( + Responses( + model_id, + vision=True, + reasoning=True, + verbosity=True, + supports_schema=True, + supports_tools=True, + ), + AsyncResponses( + model_id, + vision=True, + reasoning=True, + verbosity=True, + supports_schema=True, + supports_tools=True, + ), + ) + # "gpt-5.2-pro" is Responses API only + + # GPT-5.4 + for model_id in ( + "gpt-5.4", + "gpt-5.4-2026-03-05", + "gpt-5.4-mini", + "gpt-5.4-mini-2026-03-17", + "gpt-5.4-nano", + "gpt-5.4-nano-2026-03-17", + ): + register( + Responses( + model_id, + vision=True, + reasoning=True, + verbosity=True, + image_detail_original=True, + supports_schema=True, + supports_tools=True, + ), + AsyncResponses( + model_id, + vision=True, + reasoning=True, + verbosity=True, + image_detail_original=True, + supports_schema=True, + supports_tools=True, + ), + ) + # GPT-5.5 — routes through the Responses API by default; pass + # ``-o chat_completions 1`` to fall back to /v1/chat/completions. + for model_id in ( + "gpt-5.5", + "gpt-5.5-2026-04-23", + ): + register( + Responses( + model_id, + vision=True, + reasoning=True, + verbosity=True, + image_detail_original=True, + supports_schema=True, + supports_tools=True, + ), + AsyncResponses( + model_id, + vision=True, + reasoning=True, + verbosity=True, + image_detail_original=True, + supports_schema=True, + supports_tools=True, + ), + ) + + # The -instruct completion model + register( + Completion("gpt-3.5-turbo-instruct", default_max_tokens=256), + aliases=("3.5-instruct", "chatgpt-instruct"), + ) + + # Load extra models + extra_path = llm.user_dir() / "extra-openai-models.yaml" + if not extra_path.exists(): + return + with open(extra_path) as f: + extra_models = yaml.safe_load(f) + for extra_model in extra_models: + model_id = extra_model["model_id"] + aliases = extra_model.get("aliases", []) + model_name = extra_model["model_name"] + api_base = extra_model.get("api_base") + api_type = extra_model.get("api_type") + api_version = extra_model.get("api_version") + api_engine = extra_model.get("api_engine") + headers = extra_model.get("headers") + reasoning = extra_model.get("reasoning") + kwargs = {} + if extra_model.get("can_stream") is False: + kwargs["can_stream"] = False + if extra_model.get("supports_schema") is True: + kwargs["supports_schema"] = True + if extra_model.get("supports_tools") is True: + kwargs["supports_tools"] = True + if extra_model.get("vision") is True: + kwargs["vision"] = True + if extra_model.get("audio") is True: + kwargs["audio"] = True + if extra_model.get("completion"): + klass = Completion + async_klass = None + elif extra_model.get("responses"): + klass = Responses + async_klass = AsyncResponses + else: + klass = Chat + async_klass = AsyncChat + model_kwargs = dict( + model_id=model_id, + model_name=model_name, + api_base=api_base, + api_type=api_type, + api_version=api_version, + api_engine=api_engine, + headers=headers, + reasoning=reasoning, + **kwargs, + ) + chat_model = klass(**model_kwargs) + async_model = async_klass(**model_kwargs) if async_klass else None + if api_base: + chat_model.needs_key = None + if async_model: + async_model.needs_key = None + if extra_model.get("api_key_name"): + chat_model.needs_key = extra_model["api_key_name"] + if async_model: + async_model.needs_key = extra_model["api_key_name"] + register( + chat_model, + async_model, + aliases=aliases, + ) + + +@hookimpl +def register_embedding_models(register): + register( + OpenAIEmbeddingModel("text-embedding-ada-002", "text-embedding-ada-002"), + aliases=( + "ada", + "ada-002", + ), + ) + register( + OpenAIEmbeddingModel("text-embedding-3-small", "text-embedding-3-small"), + aliases=("3-small",), + ) + register( + OpenAIEmbeddingModel("text-embedding-3-large", "text-embedding-3-large"), + aliases=("3-large",), + ) + # With varying dimensions + register( + OpenAIEmbeddingModel( + "text-embedding-3-small-512", "text-embedding-3-small", 512 + ), + aliases=("3-small-512",), + ) + register( + OpenAIEmbeddingModel( + "text-embedding-3-large-256", "text-embedding-3-large", 256 + ), + aliases=("3-large-256",), + ) + register( + OpenAIEmbeddingModel( + "text-embedding-3-large-1024", "text-embedding-3-large", 1024 + ), + aliases=("3-large-1024",), + ) + + +class OpenAIEmbeddingModel(EmbeddingModel): + needs_key = "openai" + key_env_var = "OPENAI_API_KEY" + batch_size = 100 + + def __init__(self, model_id, openai_model_id, dimensions=None): + self.model_id = model_id + self.openai_model_id = openai_model_id + self.dimensions = dimensions + + def embed_batch(self, items: Iterable[Union[str, bytes]]) -> Iterator[List[float]]: + kwargs = { + "input": items, + "model": self.openai_model_id, + } + if self.dimensions: + kwargs["dimensions"] = self.dimensions + client = openai.OpenAI(api_key=self.get_key()) + results = client.embeddings.create(**kwargs).data + return ([float(r) for r in result.embedding] for result in results) + + +@hookimpl +def register_commands(cli): + @cli.group(name="openai") + def openai_(): + "Commands for working directly with the OpenAI API" + + @openai_.command() + @click.option("json_", "--json", is_flag=True, help="Output as JSON") + @click.option("--key", help="OpenAI API key") + def models(json_, key): + "List models available to you from the OpenAI API" + from llm import get_key + + api_key = get_key(key, "openai", "OPENAI_API_KEY") + response = httpx.get( + "https://api.openai.com/v1/models", + headers={"Authorization": f"Bearer {api_key}"}, + ) + if response.status_code != 200: + raise click.ClickException( + f"Error {response.status_code} from OpenAI API: {response.text}" + ) + models = response.json()["data"] + if json_: + click.echo(json.dumps(models, indent=4)) + else: + to_print = [] + for model in models: + # Print id, owned_by, root, created as ISO 8601 + created_str = datetime.datetime.fromtimestamp( + model["created"], datetime.timezone.utc + ).isoformat() + to_print.append( + { + "id": model["id"], + "owned_by": model["owned_by"], + "created": created_str, + } + ) + done = dicts_to_table_string("id owned_by created".split(), to_print) + print("\n".join(done)) + + +class SharedOptions(llm.Options): + temperature: Optional[float] = Field( + description=( + "What sampling temperature to use, between 0 and 2. Higher values like " + "0.8 will make the output more random, while lower values like 0.2 will " + "make it more focused and deterministic." + ), + ge=0, + le=2, + default=None, + ) + max_tokens: Optional[int] = Field( + description="Maximum number of tokens to generate.", default=None + ) + top_p: Optional[float] = Field( + description=( + "An alternative to sampling with temperature, called nucleus sampling, " + "where the model considers the results of the tokens with top_p " + "probability mass. So 0.1 means only the tokens comprising the top " + "10% probability mass are considered. Recommended to use top_p or " + "temperature but not both." + ), + ge=0, + le=1, + default=None, + ) + frequency_penalty: Optional[float] = Field( + description=( + "Number between -2.0 and 2.0. Positive values penalize new tokens based " + "on their existing frequency in the text so far, decreasing the model's " + "likelihood to repeat the same line verbatim." + ), + ge=-2, + le=2, + default=None, + ) + presence_penalty: Optional[float] = Field( + description=( + "Number between -2.0 and 2.0. Positive values penalize new tokens based " + "on whether they appear in the text so far, increasing the model's " + "likelihood to talk about new topics." + ), + ge=-2, + le=2, + default=None, + ) + stop: Optional[str] = Field( + description=("A string where the API will stop generating further tokens."), + default=None, + ) + logit_bias: Optional[Union[dict, str]] = Field( + description=( + "Modify the likelihood of specified tokens appearing in the completion. " + 'Pass a JSON string like \'{"1712":-100, "892":-100, "1489":-100}\'' + ), + default=None, + ) + seed: Optional[int] = Field( + description="Integer seed to attempt to sample deterministically", + default=None, + ) + + @field_validator("logit_bias") + def validate_logit_bias(cls, logit_bias): + if logit_bias is None: + return None + + if isinstance(logit_bias, str): + try: + logit_bias = json.loads(logit_bias) + except json.JSONDecodeError: + raise ValueError("Invalid JSON in logit_bias string") + + validated_logit_bias = {} + for key, value in logit_bias.items(): + try: + int_key = int(key) + int_value = int(value) + if -100 <= int_value <= 100: + validated_logit_bias[int_key] = int_value + else: + raise ValueError("Value must be between -100 and 100") + except ValueError: + raise ValueError("Invalid key-value pair in logit_bias dictionary") + + return validated_logit_bias + + +class ReasoningEffortEnum(str, Enum): + none = "none" + minimal = "minimal" + low = "low" + medium = "medium" + high = "high" + xhigh = "xhigh" + + +class VerbosityEnum(str, Enum): + low = "low" + medium = "medium" + high = "high" + + +class ImageDetailEnum(str, Enum): + low = "low" + high = "high" + auto = "auto" + + +class ImageDetailWithOriginalEnum(str, Enum): + low = "low" + high = "high" + original = "original" + auto = "auto" + + +def enum_values_sentence(enum_class): + values = [item.value for item in enum_class] + if len(values) == 1: + return values[0] + return "{}, and {}".format(", ".join(values[:-1]), values[-1]) + + +def build_options_class( + *, + reasoning=False, + verbosity=False, + image_detail_original=False, + chat_completions=False, +): + fields = { + "json_object": ( + Optional[bool], + Field( + description="Output a valid JSON object {...}. Prompt must mention JSON.", + default=None, + ), + ) + } + if chat_completions: + fields["chat_completions"] = ( + Optional[bool], + Field( + description=( + "Force the use of the older /v1/chat/completions endpoint " + "instead of /v1/responses. Most callers should leave this " + "off; set to true to fall back to the Chat Completions code " + "path for compatibility." + ), + default=None, + ), + ) + image_detail_enum = ( + ImageDetailWithOriginalEnum if image_detail_original else ImageDetailEnum + ) + image_detail_values = enum_values_sentence(image_detail_enum) + fields["image_detail"] = ( + Optional[image_detail_enum], + Field( + description=( + "Controls the detail level for image attachments. Supported values are " + f"{image_detail_values}." + ), + default=None, + ), + ) + if reasoning: + fields["reasoning_effort"] = ( + Optional[ReasoningEffortEnum], + Field( + description=( + "Constraints effort on reasoning for reasoning models. Currently " + "supported values are low, medium, and high. Reducing reasoning " + "effort can result in faster responses and fewer tokens used on " + "reasoning in a response." + ), + default=None, + ), + ) + if verbosity: + fields["verbosity"] = ( + Optional[VerbosityEnum], + Field( + description=( + "Controls how verbose the model's response should be. Supported " + "values are low, medium, and high." + ), + default=None, + ), + ) + return create_model("Options", __base__=SharedOptions, **fields) + + +def _attachment(attachment, image_detail=None): + url = attachment.url + base64_content = "" + if not url or attachment.resolve_type().startswith("audio/"): + base64_content = attachment.base64_content() + url = f"data:{attachment.resolve_type()};base64,{base64_content}" + if attachment.resolve_type() == "application/pdf": + if not base64_content: + base64_content = attachment.base64_content() + return { + "type": "file", + "file": { + "filename": f"{attachment.id()}.pdf", + "file_data": f"data:application/pdf;base64,{base64_content}", + }, + } + if attachment.resolve_type().startswith("image/"): + image_url = {"url": url} + if image_detail: + image_url["detail"] = image_detail + return {"type": "image_url", "image_url": image_url} + else: + format_ = "wav" if attachment.resolve_type() == "audio/wav" else "mp3" + return { + "type": "input_audio", + "input_audio": { + "data": base64_content, + "format": format_, + }, + } + + +class _Shared: + def __init__( + self, + model_id, + key=None, + model_name=None, + api_base=None, + api_type=None, + api_version=None, + api_engine=None, + headers=None, + can_stream=True, + vision=False, + audio=False, + reasoning=False, + verbosity=False, + image_detail_original=False, + supports_schema=False, + supports_tools=False, + allows_system_prompt=True, + ): + self.model_id = model_id + self.key = key + self.supports_schema = supports_schema + self.supports_tools = supports_tools + self.model_name = model_name + self.api_base = api_base + self.api_type = api_type + self.api_version = api_version + self.api_engine = api_engine + self.headers = headers + self.can_stream = can_stream + self.vision = vision + self.allows_system_prompt = allows_system_prompt + + self.attachment_types = set() + + if reasoning or verbosity or image_detail_original: + self.Options = build_options_class( + reasoning=reasoning, + verbosity=verbosity, + image_detail_original=image_detail_original, + ) + + if vision: + self.attachment_types.update( + { + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "application/pdf", + } + ) + + if audio: + self.attachment_types.update( + { + "audio/wav", + "audio/mpeg", + } + ) + + def __str__(self) -> str: + return "OpenAI Chat: {}".format(self.model_id) + + def _append_llm_message(self, out, message, current_system, image_detail=None): + """Translate one llm.Message into one (or more) OpenAI message + dicts and append them to ``out``. + + Returns the (possibly updated) current_system value so the caller + can avoid re-emitting an unchanged system prompt. + """ + from llm.parts import ( + AttachmentPart, + TextPart, + ToolCallPart, + ToolResultPart, + ) + + text_bits = [] + attachment_items = [] + tool_calls = [] + tool_results = [] + + for part in message.parts: + if isinstance(part, TextPart): + text_bits.append(part.text) + elif isinstance(part, AttachmentPart) and part.attachment: + attachment_items.append( + _attachment(part.attachment, image_detail=image_detail) + ) + elif isinstance(part, ToolCallPart): + tool_calls.append( + { + "type": "function", + "id": part.tool_call_id, + "function": { + "name": part.name, + "arguments": json.dumps(part.arguments), + }, + } + ) + elif isinstance(part, ToolResultPart): + tool_results.append( + { + "role": "tool", + "tool_call_id": part.tool_call_id, + "content": part.output, + } + ) + + # Role "tool" emits one OpenAI "tool" message per ToolResultPart. + if message.role == "tool": + out.extend(tool_results) + return current_system + + # System dedup: skip if this text is already the active system prompt. + if message.role == "system": + text = "".join(text_bits) + if text == current_system: + return current_system + current_system = text + + if attachment_items: + content = [] + if text_bits: + content.append({"type": "text", "text": "".join(text_bits)}) + content.extend(attachment_items) + entry = {"role": message.role, "content": content} + else: + entry = { + "role": message.role, + "content": "".join(text_bits) if text_bits else None, + } + + if tool_calls: + entry["tool_calls"] = tool_calls + # OpenAI expects content=null when only tool_calls are present. + if not text_bits: + entry["content"] = None + elif entry["content"] is None and message.role != "assistant": + # For user/system, an empty message is pointless — drop it. + return current_system + + out.append(entry) + return current_system + + def build_messages(self, prompt, conversation, image_detail=None): + """Translate prompt.messages into OpenAI's wire format.""" + messages: List[Dict[str, Any]] = [] + if image_detail is not None: + image_detail = image_detail.value + current_system: Optional[str] = None + for msg in prompt.messages: + current_system = self._append_llm_message( + messages, msg, current_system, image_detail=image_detail + ) + return messages + + def set_usage(self, response, usage): + if not usage: + return + input_tokens = usage.pop("prompt_tokens") + output_tokens = usage.pop("completion_tokens") + usage.pop("total_tokens") + response.set_usage( + input=input_tokens, output=output_tokens, details=simplify_usage_dict(usage) + ) + + def get_client(self, key, *, async_=False): + kwargs = {} + if self.api_base: + kwargs["base_url"] = self.api_base + if self.api_type: + kwargs["api_type"] = self.api_type + if self.api_version: + kwargs["api_version"] = self.api_version + if self.api_engine: + kwargs["engine"] = self.api_engine + if self.needs_key: + kwargs["api_key"] = self.get_key(key) + else: + # OpenAI-compatible models don't need a key, but the + # openai client library requires one + kwargs["api_key"] = "DUMMY_KEY" + if self.headers: + kwargs["default_headers"] = self.headers + if os.environ.get("LLM_OPENAI_SHOW_RESPONSES"): + kwargs["http_client"] = logging_client() + if async_: + return openai.AsyncOpenAI(**kwargs) + else: + return openai.OpenAI(**kwargs) + + def build_kwargs(self, prompt, stream): + kwargs = dict(not_nulls(prompt.options)) + json_object = kwargs.pop("json_object", None) + kwargs.pop("image_detail", None) + kwargs.pop("chat_completions", None) + if "max_tokens" not in kwargs and self.default_max_tokens is not None: + kwargs["max_tokens"] = self.default_max_tokens + if json_object: + kwargs["response_format"] = {"type": "json_object"} + if prompt.schema: + kwargs["response_format"] = { + "type": "json_schema", + "json_schema": {"name": "output", "schema": prompt.schema}, + } + if prompt.tools: + kwargs["tools"] = [ + { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description or None, + "parameters": tool.input_schema, + }, + } + for tool in prompt.tools + ] + if stream: + kwargs["stream_options"] = {"include_usage": True} + return kwargs + + +class Chat(_Shared, KeyModel): + needs_key = "openai" + key_env_var = "OPENAI_API_KEY" + default_max_tokens = None + + Options = build_options_class() + + def execute( + self, + prompt: Prompt, + stream: bool, + response: Response, + conversation: Optional[Conversation] = None, + key: Optional[str] = None, + ) -> Iterator[Union[str, StreamEvent]]: + if prompt.system and not self.allows_system_prompt: + raise NotImplementedError("Model does not support system prompts") + messages = self.build_messages( + prompt, + conversation, + image_detail=getattr(prompt.options, "image_detail", None), + ) + kwargs = self.build_kwargs(prompt, stream) + client = self.get_client(key) + usage = None + if stream: + completion = client.chat.completions.create( + model=self.model_name or self.model_id, + messages=messages, + stream=True, + **kwargs, + ) + chunks = [] + tool_calls = {} + for chunk in completion: + chunks.append(chunk) + if chunk.usage: + usage = chunk.usage.model_dump() + if chunk.choices and chunk.choices[0].delta: + for tool_call in chunk.choices[0].delta.tool_calls or []: + if tool_call.function.arguments is None: + tool_call.function.arguments = "" + idx = tool_call.index + if idx not in tool_calls: + tool_calls[idx] = tool_call + yield StreamEvent( + type="tool_call_name", + chunk=tool_call.function.name or "", + tool_call_id=tool_call.id, + ) + else: + tool_calls[ + idx + ].function.arguments += tool_call.function.arguments + if tool_call.function.arguments: + yield StreamEvent( + type="tool_call_args", + chunk=tool_call.function.arguments, + tool_call_id=tool_calls[idx].id, + ) + try: + content = chunk.choices[0].delta.content + except IndexError: + content = None + if content: + # Empty strings are noise (OpenAI's first chunk + # with role=assistant has content=""). + yield StreamEvent(type="text", chunk=content) + response.response_json = remove_dict_none_values(combine_chunks(chunks)) + if tool_calls: + for value in tool_calls.values(): + response.add_tool_call( + llm.ToolCall( + tool_call_id=value.id, + name=value.function.name, + arguments=json.loads(value.function.arguments or "{}"), + ) + ) + else: + completion = client.chat.completions.create( + model=self.model_name or self.model_id, + messages=messages, + stream=False, + **kwargs, + ) + usage = completion.usage.model_dump() + response.response_json = remove_dict_none_values(completion.model_dump()) + for tool_call in completion.choices[0].message.tool_calls or []: + response.add_tool_call( + llm.ToolCall( + tool_call_id=tool_call.id, + name=tool_call.function.name, + arguments=json.loads(tool_call.function.arguments or "{}"), + ) + ) + yield StreamEvent( + type="tool_call_name", + chunk=tool_call.function.name or "", + tool_call_id=tool_call.id, + ) + yield StreamEvent( + type="tool_call_args", + chunk=tool_call.function.arguments or "", + tool_call_id=tool_call.id, + ) + if completion.choices[0].message.content is not None: + yield StreamEvent( + type="text", + chunk=completion.choices[0].message.content, + ) + self.set_usage(response, usage) + if usage and (usage.get("completion_tokens_details") or {}).get( + "reasoning_tokens" + ): + yield StreamEvent(type="reasoning", chunk="", redacted=True) + response._prompt_json = redact_data({"messages": messages}) + + +class AsyncChat(_Shared, AsyncKeyModel): + needs_key = "openai" + key_env_var = "OPENAI_API_KEY" + default_max_tokens = None + + Options = build_options_class() + + async def execute( + self, + prompt: Prompt, + stream: bool, + response: AsyncResponse, + conversation: Optional[AsyncConversation] = None, + key: Optional[str] = None, + ) -> AsyncGenerator[Union[str, StreamEvent], None]: + if prompt.system and not self.allows_system_prompt: + raise NotImplementedError("Model does not support system prompts") + messages = self.build_messages( + prompt, + conversation, + image_detail=getattr(prompt.options, "image_detail", None), + ) + kwargs = self.build_kwargs(prompt, stream) + client = self.get_client(key, async_=True) + usage = None + if stream: + completion = await client.chat.completions.create( + model=self.model_name or self.model_id, + messages=messages, + stream=True, + **kwargs, + ) + chunks = [] + tool_calls = {} + async for chunk in completion: + if chunk.usage: + usage = chunk.usage.model_dump() + chunks.append(chunk) + if chunk.choices and chunk.choices[0].delta: + for tool_call in chunk.choices[0].delta.tool_calls or []: + if tool_call.function.arguments is None: + tool_call.function.arguments = "" + idx = tool_call.index + if idx not in tool_calls: + tool_calls[idx] = tool_call + yield StreamEvent( + type="tool_call_name", + chunk=tool_call.function.name or "", + tool_call_id=tool_call.id, + ) + else: + tool_calls[ + idx + ].function.arguments += tool_call.function.arguments + if tool_call.function.arguments: + yield StreamEvent( + type="tool_call_args", + chunk=tool_call.function.arguments, + tool_call_id=tool_calls[idx].id, + ) + try: + content = chunk.choices[0].delta.content + except IndexError: + content = None + if content: + yield StreamEvent(type="text", chunk=content) + if tool_calls: + for value in tool_calls.values(): + response.add_tool_call( + llm.ToolCall( + tool_call_id=value.id, + name=value.function.name, + arguments=json.loads(value.function.arguments or "{}"), + ) + ) + response.response_json = remove_dict_none_values(combine_chunks(chunks)) + else: + completion = await client.chat.completions.create( + model=self.model_name or self.model_id, + messages=messages, + stream=False, + **kwargs, + ) + response.response_json = remove_dict_none_values(completion.model_dump()) + usage = completion.usage.model_dump() + for tool_call in completion.choices[0].message.tool_calls or []: + response.add_tool_call( + llm.ToolCall( + tool_call_id=tool_call.id, + name=tool_call.function.name, + arguments=json.loads(tool_call.function.arguments or "{}"), + ) + ) + yield StreamEvent( + type="tool_call_name", + chunk=tool_call.function.name or "", + tool_call_id=tool_call.id, + ) + yield StreamEvent( + type="tool_call_args", + chunk=tool_call.function.arguments or "", + tool_call_id=tool_call.id, + ) + if completion.choices[0].message.content is not None: + yield StreamEvent( + type="text", + chunk=completion.choices[0].message.content, + ) + self.set_usage(response, usage) + if usage and (usage.get("completion_tokens_details") or {}).get( + "reasoning_tokens" + ): + yield StreamEvent(type="reasoning", chunk="", redacted=True) + response._prompt_json = redact_data({"messages": messages}) + + +def _responses_attachment(attachment, image_detail=None): + """Translate an llm Attachment into a Responses-API content part.""" + url = attachment.url + base64_content = "" + if not url or attachment.resolve_type().startswith("audio/"): + base64_content = attachment.base64_content() + url = f"data:{attachment.resolve_type()};base64,{base64_content}" + if attachment.resolve_type() == "application/pdf": + if not base64_content: + base64_content = attachment.base64_content() + return { + "type": "input_file", + "filename": f"{attachment.id()}.pdf", + "file_data": f"data:application/pdf;base64,{base64_content}", + } + if attachment.resolve_type().startswith("image/"): + item = {"type": "input_image", "image_url": url} + if image_detail: + item["detail"] = image_detail + return item + # Audio is not yet supported on the Responses input shape we use; fall + # back to image_url for unknown types so we don't silently drop content. + return {"type": "input_image", "image_url": url} + + +class _SharedResponses(_Shared): + """Mixin that translates llm.Prompt into Responses API parameters.""" + + def __str__(self) -> str: + return "OpenAI Responses: {}".format(self.model_id) + + def _delegate_chat_kwargs(self): + """Return constructor kwargs that mirror this Responses model so we + can build a sibling Chat / AsyncChat instance for the + ``-o chat_completions 1`` opt-out path.""" + return dict( + model_id=self.model_id, + key=self.key, + model_name=self.model_name, + api_base=self.api_base, + api_type=self.api_type, + api_version=self.api_version, + api_engine=self.api_engine, + headers=self.headers, + can_stream=self.can_stream, + vision=self.vision, + reasoning=self._reasoning, + verbosity=self._verbosity, + image_detail_original=self._image_detail_original, + supports_schema=self.supports_schema, + supports_tools=self.supports_tools, + allows_system_prompt=self.allows_system_prompt, + ) + + def _build_responses_input(self, prompt, image_detail=None): + """Translate prompt.messages into a (input_items, instructions) tuple + for the Responses API. + + The most recent system Message is hoisted into ``instructions``; + earlier system messages are dropped (mirroring the way the Chat + path collapses repeated identical system prompts). + """ + from llm.parts import ( + AttachmentPart, + ReasoningPart, + TextPart, + ToolCallPart, + ToolResultPart, + ) + + items: List[Dict[str, Any]] = [] + instructions: Optional[str] = None + + for msg in prompt.messages: + if msg.role == "system": + text = "".join(p.text for p in msg.parts if isinstance(p, TextPart)) + if text: + instructions = text + continue + + text_bits: List[str] = [] + attachment_items: List[Dict[str, Any]] = [] + tool_call_items: List[Dict[str, Any]] = [] + tool_result_items: List[Dict[str, Any]] = [] + reasoning_items: List[Dict[str, Any]] = [] + + for part in msg.parts: + if isinstance(part, TextPart): + text_bits.append(part.text) + elif isinstance(part, AttachmentPart) and part.attachment: + attachment_items.append( + _responses_attachment( + part.attachment, image_detail=image_detail + ) + ) + elif isinstance(part, ToolCallPart): + tool_call_items.append( + { + "type": "function_call", + "call_id": part.tool_call_id, + "name": part.name, + "arguments": json.dumps(part.arguments), + } + ) + elif isinstance(part, ToolResultPart): + tool_result_items.append( + { + "type": "function_call_output", + "call_id": part.tool_call_id, + "output": part.output, + } + ) + elif isinstance(part, ReasoningPart): + pm = (part.provider_metadata or {}).get("openai") or {} + enc = pm.get("encrypted_content") + rid = pm.get("id") + if enc or rid: + # Round-trip a previous reasoning item so the model + # can pick up where it left off mid-tool-call. + item: Dict[str, Any] = {"type": "reasoning"} + if rid: + item["id"] = rid + if enc: + item["encrypted_content"] = enc + if pm.get("summary"): + item["summary"] = pm["summary"] + else: + item["summary"] = [] + reasoning_items.append(item) + + # Reasoning items must precede the assistant message / function + # call they belonged to. + items.extend(reasoning_items) + + if msg.role == "tool": + items.extend(tool_result_items) + continue + + if msg.role == "user": + if attachment_items: + content: List[Dict[str, Any]] = [] + if text_bits: + content.append( + {"type": "input_text", "text": "".join(text_bits)} + ) + content.extend(attachment_items) + items.append({"role": "user", "content": content}) + elif text_bits: + items.append({"role": "user", "content": "".join(text_bits)}) + elif msg.role == "assistant": + if text_bits: + items.append({"role": "assistant", "content": "".join(text_bits)}) + items.extend(tool_call_items) + + return items, instructions + + def _build_responses_kwargs(self, prompt, stream): + """Build the keyword arguments for client.responses.create().""" + opts = dict(not_nulls(prompt.options)) + # Strip options that are either internal to llm or not accepted by + # the Responses API. + opts.pop("json_object", None) + opts.pop("chat_completions", None) + opts.pop("image_detail", None) + max_tokens = opts.pop("max_tokens", None) + reasoning_effort = opts.pop("reasoning_effort", None) + verbosity = opts.pop("verbosity", None) + temperature = opts.pop("temperature", None) + top_p = opts.pop("top_p", None) + seed = opts.pop("seed", None) + + kwargs: Dict[str, Any] = {} + if max_tokens is None and self.default_max_tokens is not None: + max_tokens = self.default_max_tokens + if max_tokens is not None: + kwargs["max_output_tokens"] = max_tokens + if temperature is not None: + kwargs["temperature"] = temperature + if top_p is not None: + kwargs["top_p"] = top_p + if seed is not None: + kwargs["seed"] = seed + if self._reasoning: + reasoning = {} + if not getattr(prompt, "hide_reasoning", False): + reasoning["summary"] = "auto" + if reasoning_effort: + reasoning["effort"] = reasoning_effort + if reasoning: + kwargs["reasoning"] = reasoning + + text: Dict[str, Any] = {} + if verbosity: + text["verbosity"] = verbosity + if prompt.options.json_object: + text["format"] = {"type": "json_object"} + if prompt.schema: + # ``strict: False`` mirrors the looser behaviour of the + # /v1/chat/completions json_schema response_format - required + # because the Responses API otherwise insists on + # ``additionalProperties: false`` everywhere. + text["format"] = { + "type": "json_schema", + "name": "output", + "schema": prompt.schema, + "strict": False, + } + if text: + kwargs["text"] = text + + if prompt.tools: + kwargs["tools"] = [ + { + "type": "function", + "name": tool.name, + "description": tool.description or None, + "parameters": tool.input_schema, + } + for tool in prompt.tools + ] + + # Pass anything we did not consume through verbatim - this lets + # extras like ``parallel_tool_calls`` flow into the API. + kwargs.update(opts) + return kwargs + + def _set_usage_responses(self, response, usage): + if not usage: + return + input_tokens = usage.get("input_tokens", 0) or 0 + output_tokens = usage.get("output_tokens", 0) or 0 + details = {} + for key in ("input_tokens_details", "output_tokens_details"): + value = usage.get(key) + if value: + details[key] = value + response.set_usage( + input=input_tokens, output=output_tokens, details=details or None + ) + + def _reasoning_text_from_item(self, item): + bits = [] + for attr in ("summary", "content"): + for part in getattr(item, attr, None) or []: + if isinstance(part, dict): + text = part.get("text") + else: + text = getattr(part, "text", None) + if text: + bits.append(text) + return "".join(bits) + + def _reasoning_event(self, item, *, include_text=True): + """Build a redacted-reasoning StreamEvent that carries the opaque + ``id`` and ``encrypted_content`` from a Responses-API reasoning + item. Echoing this metadata back on the next request via + ``_build_responses_input`` lets the model pick up its prior chain + of thought - critical for tool-using reasoning models, since + without it the model loses ~3% on SWE-bench (per OpenAI).""" + rid = getattr(item, "id", None) + enc = getattr(item, "encrypted_content", None) + summary = getattr(item, "summary", None) + text = self._reasoning_text_from_item(item) if include_text else "" + meta: Dict[str, Any] = {} + if rid: + meta["id"] = rid + if enc: + meta["encrypted_content"] = enc + if summary: + # ``summary`` is a list of {type:"summary_text", text:"..."} + # objects when reasoning summaries are enabled. + try: + meta["summary"] = [ + s.model_dump() if hasattr(s, "model_dump") else dict(s) + for s in summary + ] + except Exception: + meta["summary"] = list(summary) + return StreamEvent( + type="reasoning", + chunk=text, + redacted=include_text and not text, + provider_metadata={"openai": meta} if meta else None, + ) + + +class Responses(_SharedResponses, KeyModel): + needs_key = "openai" + key_env_var = "OPENAI_API_KEY" + default_max_tokens = None + + def __init__( + self, + model_id, + key=None, + model_name=None, + api_base=None, + api_type=None, + api_version=None, + api_engine=None, + headers=None, + can_stream=True, + vision=False, + audio=False, + reasoning=False, + verbosity=False, + image_detail_original=False, + supports_schema=False, + supports_tools=False, + allows_system_prompt=True, + ): + super().__init__( + model_id, + key=key, + model_name=model_name, + api_base=api_base, + api_type=api_type, + api_version=api_version, + api_engine=api_engine, + headers=headers, + can_stream=can_stream, + vision=vision, + audio=audio, + reasoning=reasoning, + verbosity=verbosity, + image_detail_original=image_detail_original, + supports_schema=supports_schema, + supports_tools=supports_tools, + allows_system_prompt=allows_system_prompt, + ) + self._reasoning = reasoning + self._verbosity = verbosity + self._image_detail_original = image_detail_original + # Override the Options class so that ``-o chat_completions 1`` is + # always available on Responses-routed models. + self.Options = build_options_class( + reasoning=reasoning, + verbosity=verbosity, + image_detail_original=image_detail_original, + chat_completions=True, + ) + + def execute( + self, + prompt: Prompt, + stream: bool, + response: Response, + conversation: Optional[Conversation] = None, + key: Optional[str] = None, + ) -> Iterator[Union[str, StreamEvent]]: + if getattr(prompt.options, "chat_completions", None): + chat = Chat(**self._delegate_chat_kwargs()) + yield from chat.execute(prompt, stream, response, conversation, key) + return + + if prompt.system and not self.allows_system_prompt: + raise NotImplementedError("Model does not support system prompts") + + image_detail = getattr(prompt.options, "image_detail", None) + if image_detail is not None: + image_detail = image_detail.value + input_items, instructions = self._build_responses_input( + prompt, image_detail=image_detail + ) + kwargs = self._build_responses_kwargs(prompt, stream) + if instructions is not None: + kwargs["instructions"] = instructions + kwargs["store"] = False + if self._reasoning: + kwargs["include"] = ["reasoning.encrypted_content"] + + client = self.get_client(key) + usage = None + had_reasoning = False + if stream: + stream_obj = client.responses.create( + model=self.model_name or self.model_id, + input=input_items, + stream=True, + **kwargs, + ) + tool_call_meta: Dict[str, Dict[str, str]] = {} + final_response_dict: Optional[Dict[str, Any]] = None + reasoning_items_with_streamed_text = set() + for event in stream_obj: + etype = getattr(event, "type", None) + if etype == "response.output_item.added": + item = event.item + if item.type == "function_call": + tool_call_meta[item.id] = { + "id": item.id, + "call_id": item.call_id, + "name": item.name, + } + yield StreamEvent( + type="tool_call_name", + chunk=item.name or "", + tool_call_id=item.call_id, + ) + elif etype == "response.output_text.delta": + yield StreamEvent(type="text", chunk=event.delta or "") + elif etype == "response.function_call_arguments.delta": + item_id = getattr(event, "item_id", None) + meta = tool_call_meta.get(item_id) if item_id else None + call_id = meta["call_id"] if meta else None + yield StreamEvent( + type="tool_call_args", + chunk=event.delta or "", + tool_call_id=call_id, + ) + elif etype in ( + "response.reasoning_summary_text.delta", + "response.reasoning_text.delta", + ): + item_id = getattr(event, "item_id", None) + if item_id: + reasoning_items_with_streamed_text.add(item_id) + yield StreamEvent(type="reasoning", chunk=event.delta or "") + elif etype in ( + "response.reasoning_summary_text.done", + "response.reasoning_text.done", + ): + item_id = getattr(event, "item_id", None) + if item_id not in reasoning_items_with_streamed_text: + text = getattr(event, "text", None) or "" + if text: + if item_id: + reasoning_items_with_streamed_text.add(item_id) + yield StreamEvent(type="reasoning", chunk=text) + elif etype == "response.output_item.done": + item = event.item + if item.type == "reasoning": + had_reasoning = True + item_id = getattr(item, "id", None) + yield self._reasoning_event( + item, + include_text=( + item_id not in reasoning_items_with_streamed_text + ), + ) + elif item.type == "function_call": + try: + args = json.loads(item.arguments) if item.arguments else {} + except json.JSONDecodeError: + args = {"_raw": item.arguments} + response.add_tool_call( + llm.ToolCall( + tool_call_id=item.call_id, + name=item.name, + arguments=args, + ) + ) + elif etype == "response.completed": + final_response_dict = event.response.model_dump() + if final_response_dict.get("usage"): + usage = final_response_dict["usage"] + if final_response_dict is not None: + response.response_json = remove_dict_none_values(final_response_dict) + else: + completion = client.responses.create( + model=self.model_name or self.model_id, + input=input_items, + stream=False, + **kwargs, + ) + dumped = completion.model_dump() + response.response_json = remove_dict_none_values(dumped) + usage = dumped.get("usage") + for item in completion.output: + if item.type == "reasoning": + had_reasoning = True + yield self._reasoning_event(item) + elif item.type == "function_call": + try: + args = json.loads(item.arguments) if item.arguments else {} + except json.JSONDecodeError: + args = {"_raw": item.arguments} + response.add_tool_call( + llm.ToolCall( + tool_call_id=item.call_id, + name=item.name, + arguments=args, + ) + ) + yield StreamEvent( + type="tool_call_name", + chunk=item.name or "", + tool_call_id=item.call_id, + ) + yield StreamEvent( + type="tool_call_args", + chunk=item.arguments or "", + tool_call_id=item.call_id, + ) + elif item.type == "message": + for content in item.content or []: + ctype = getattr(content, "type", None) + if ctype == "output_text" and content.text: + yield StreamEvent(type="text", chunk=content.text) + + self._set_usage_responses(response, usage) + # Fallback: usage said reasoning happened but the API gave us no + # reasoning items to harvest encrypted_content from. Emit the + # opaque "reasoning happened" marker for UI / token accounting. + if ( + not had_reasoning + and usage + and ((usage.get("output_tokens_details") or {}).get("reasoning_tokens")) + ): + yield StreamEvent(type="reasoning", chunk="", redacted=True) + response._prompt_json = redact_data( + {"input": input_items, "instructions": instructions} + ) + + +class AsyncResponses(_SharedResponses, AsyncKeyModel): + needs_key = "openai" + key_env_var = "OPENAI_API_KEY" + default_max_tokens = None + + def __init__( + self, + model_id, + key=None, + model_name=None, + api_base=None, + api_type=None, + api_version=None, + api_engine=None, + headers=None, + can_stream=True, + vision=False, + audio=False, + reasoning=False, + verbosity=False, + image_detail_original=False, + supports_schema=False, + supports_tools=False, + allows_system_prompt=True, + ): + super().__init__( + model_id, + key=key, + model_name=model_name, + api_base=api_base, + api_type=api_type, + api_version=api_version, + api_engine=api_engine, + headers=headers, + can_stream=can_stream, + vision=vision, + audio=audio, + reasoning=reasoning, + verbosity=verbosity, + image_detail_original=image_detail_original, + supports_schema=supports_schema, + supports_tools=supports_tools, + allows_system_prompt=allows_system_prompt, + ) + self._reasoning = reasoning + self._verbosity = verbosity + self._image_detail_original = image_detail_original + self.Options = build_options_class( + reasoning=reasoning, + verbosity=verbosity, + image_detail_original=image_detail_original, + chat_completions=True, + ) + + async def execute( + self, + prompt: Prompt, + stream: bool, + response: AsyncResponse, + conversation: Optional[AsyncConversation] = None, + key: Optional[str] = None, + ) -> AsyncGenerator[Union[str, StreamEvent], None]: + if getattr(prompt.options, "chat_completions", None): + chat = AsyncChat(**self._delegate_chat_kwargs()) + async for event in chat.execute( + prompt, stream, response, conversation, key + ): + yield event + return + + if prompt.system and not self.allows_system_prompt: + raise NotImplementedError("Model does not support system prompts") + + image_detail = getattr(prompt.options, "image_detail", None) + if image_detail is not None: + image_detail = image_detail.value + input_items, instructions = self._build_responses_input( + prompt, image_detail=image_detail + ) + kwargs = self._build_responses_kwargs(prompt, stream) + if instructions is not None: + kwargs["instructions"] = instructions + kwargs["store"] = False + if self._reasoning: + kwargs["include"] = ["reasoning.encrypted_content"] + + client = self.get_client(key, async_=True) + usage = None + had_reasoning = False + if stream: + stream_obj = await client.responses.create( + model=self.model_name or self.model_id, + input=input_items, + stream=True, + **kwargs, + ) + tool_call_meta: Dict[str, Dict[str, str]] = {} + final_response_dict: Optional[Dict[str, Any]] = None + reasoning_items_with_streamed_text = set() + async for event in stream_obj: + etype = getattr(event, "type", None) + if etype == "response.output_item.added": + item = event.item + if item.type == "function_call": + tool_call_meta[item.id] = { + "id": item.id, + "call_id": item.call_id, + "name": item.name, + } + yield StreamEvent( + type="tool_call_name", + chunk=item.name or "", + tool_call_id=item.call_id, + ) + elif etype == "response.output_text.delta": + yield StreamEvent(type="text", chunk=event.delta or "") + elif etype == "response.function_call_arguments.delta": + item_id = getattr(event, "item_id", None) + meta = tool_call_meta.get(item_id) if item_id else None + call_id = meta["call_id"] if meta else None + yield StreamEvent( + type="tool_call_args", + chunk=event.delta or "", + tool_call_id=call_id, + ) + elif etype in ( + "response.reasoning_summary_text.delta", + "response.reasoning_text.delta", + ): + item_id = getattr(event, "item_id", None) + if item_id: + reasoning_items_with_streamed_text.add(item_id) + yield StreamEvent(type="reasoning", chunk=event.delta or "") + elif etype in ( + "response.reasoning_summary_text.done", + "response.reasoning_text.done", + ): + item_id = getattr(event, "item_id", None) + if item_id not in reasoning_items_with_streamed_text: + text = getattr(event, "text", None) or "" + if text: + if item_id: + reasoning_items_with_streamed_text.add(item_id) + yield StreamEvent(type="reasoning", chunk=text) + elif etype == "response.output_item.done": + item = event.item + if item.type == "reasoning": + had_reasoning = True + item_id = getattr(item, "id", None) + yield self._reasoning_event( + item, + include_text=( + item_id not in reasoning_items_with_streamed_text + ), + ) + elif item.type == "function_call": + try: + args = json.loads(item.arguments) if item.arguments else {} + except json.JSONDecodeError: + args = {"_raw": item.arguments} + response.add_tool_call( + llm.ToolCall( + tool_call_id=item.call_id, + name=item.name, + arguments=args, + ) + ) + elif etype == "response.completed": + final_response_dict = event.response.model_dump() + if final_response_dict.get("usage"): + usage = final_response_dict["usage"] + if final_response_dict is not None: + response.response_json = remove_dict_none_values(final_response_dict) + else: + completion = await client.responses.create( + model=self.model_name or self.model_id, + input=input_items, + stream=False, + **kwargs, + ) + dumped = completion.model_dump() + response.response_json = remove_dict_none_values(dumped) + usage = dumped.get("usage") + for item in completion.output: + if item.type == "reasoning": + had_reasoning = True + yield self._reasoning_event(item) + elif item.type == "function_call": + try: + args = json.loads(item.arguments) if item.arguments else {} + except json.JSONDecodeError: + args = {"_raw": item.arguments} + response.add_tool_call( + llm.ToolCall( + tool_call_id=item.call_id, + name=item.name, + arguments=args, + ) + ) + yield StreamEvent( + type="tool_call_name", + chunk=item.name or "", + tool_call_id=item.call_id, + ) + yield StreamEvent( + type="tool_call_args", + chunk=item.arguments or "", + tool_call_id=item.call_id, + ) + elif item.type == "message": + for content in item.content or []: + ctype = getattr(content, "type", None) + if ctype == "output_text" and content.text: + yield StreamEvent(type="text", chunk=content.text) + + self._set_usage_responses(response, usage) + if ( + not had_reasoning + and usage + and ((usage.get("output_tokens_details") or {}).get("reasoning_tokens")) + ): + yield StreamEvent(type="reasoning", chunk="", redacted=True) + response._prompt_json = redact_data( + {"input": input_items, "instructions": instructions} + ) + + +class Completion(Chat): + class Options(SharedOptions): + logprobs: Optional[int] = Field( + description="Include the log probabilities of most likely N per token", + default=None, + le=5, + ) + + def __init__(self, *args, default_max_tokens=None, **kwargs): + super().__init__(*args, **kwargs) + self.default_max_tokens = default_max_tokens + + def __str__(self) -> str: + return "OpenAI Completion: {}".format(self.model_id) + + def execute( + self, + prompt: Prompt, + stream: bool, + response: Response, + conversation: Optional[Conversation] = None, + key: Optional[str] = None, + ) -> Iterator[Union[str, StreamEvent]]: + if prompt.system: + raise NotImplementedError( + "System prompts are not supported for OpenAI completion models" + ) + messages = [] + if conversation is not None: + for prev_response in conversation.responses: + messages.append(prev_response.prompt.prompt) + messages.append(cast(Response, prev_response).text()) + messages.append(prompt.prompt) + kwargs = self.build_kwargs(prompt, stream) + client = self.get_client(key) + if stream: + completion = client.completions.create( + model=self.model_name or self.model_id, + prompt="\n".join(messages), + stream=True, + **kwargs, + ) + chunks = [] + for chunk in completion: + chunks.append(chunk) + try: + content = chunk.choices[0].text + except IndexError: + content = None + if content is not None: + yield content + combined = combine_chunks(chunks) + cleaned = remove_dict_none_values(combined) + response.response_json = cleaned + else: + completion = client.completions.create( + model=self.model_name or self.model_id, + prompt="\n".join(messages), + stream=False, + **kwargs, + ) + response.response_json = remove_dict_none_values(completion.model_dump()) + yield completion.choices[0].text + response._prompt_json = redact_data({"messages": messages}) + + +def not_nulls(data) -> dict: + return {key: value for key, value in data if value is not None} + + +def combine_chunks(chunks: List) -> dict: + content = "" + role = None + finish_reason = None + # If any of them have log probability, we're going to persist + # those later on + logprobs = [] + usage = {} + + for item in chunks: + if item.usage: + usage = item.usage.model_dump() + for choice in item.choices: + if choice.logprobs and hasattr(choice.logprobs, "top_logprobs"): + logprobs.append( + { + "text": choice.text if hasattr(choice, "text") else None, + "top_logprobs": choice.logprobs.top_logprobs, + } + ) + + if not hasattr(choice, "delta"): + content += choice.text + continue + role = choice.delta.role + if choice.delta.content is not None: + content += choice.delta.content + if choice.finish_reason is not None: + finish_reason = choice.finish_reason + + # Imitations of the OpenAI API may be missing some of these fields + combined = { + "content": content, + "role": role, + "finish_reason": finish_reason, + "usage": usage, + } + if logprobs: + combined["logprobs"] = logprobs + if chunks: + for key in ("id", "object", "model", "created", "index"): + value = getattr(chunks[0], key, None) + if value is not None: + combined[key] = value + + return combined + + +def redact_data(input_dict): + """ + Recursively search through the input dictionary for any 'image_url' keys + and modify the 'url' value to be just 'data:...'. + + Also redact input_audio.data keys + """ + if isinstance(input_dict, dict): + for key, value in input_dict.items(): + if ( + key == "image_url" + and isinstance(value, dict) + and "url" in value + and value["url"].startswith("data:") + ): + value["url"] = "data:..." + elif key == "input_audio" and isinstance(value, dict) and "data" in value: + value["data"] = "..." + else: + redact_data(value) + elif isinstance(input_dict, list): + for item in input_dict: + redact_data(item) + return input_dict diff --git a/llm/embeddings.py b/llm/embeddings.py new file mode 100644 index 0000000..90b983a --- /dev/null +++ b/llm/embeddings.py @@ -0,0 +1,367 @@ +from .models import EmbeddingModel +from .embeddings_migrations import embeddings_migrations +from dataclasses import dataclass +import hashlib +from itertools import islice +import json +from sqlite_utils import Database +from sqlite_utils.db import Table +import time +from typing import cast, Any, Dict, Iterable, List, Optional, Tuple, Union + + +@dataclass +class Entry: + id: str + score: Optional[float] + content: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + + +class Collection: + class DoesNotExist(Exception): + pass + + def __init__( + self, + name: str, + db: Optional[Database] = None, + *, + model: Optional[EmbeddingModel] = None, + model_id: Optional[str] = None, + create: bool = True, + ) -> None: + """ + A collection of embeddings + + Returns the collection with the given name, creating it if it does not exist. + + If you set create=False a Collection.DoesNotExist exception will be raised if the + collection does not already exist. + + Args: + db (sqlite_utils.Database): Database to store the collection in + name (str): Name of the collection + model (llm.models.EmbeddingModel, optional): Embedding model to use + model_id (str, optional): Alternatively, ID of the embedding model to use + create (bool, optional): Whether to create the collection if it does not exist + """ + import llm + + self.db = db or Database(memory=True) + self.name = name + self._model = model + + embeddings_migrations.apply(self.db) + + rows = list(self.db["collections"].rows_where("name = ?", [self.name])) + if rows: + row = rows[0] + self.id = row["id"] + self.model_id = row["model"] + else: + if create: + # Collection does not exist, so model or model_id is required + if not model and not model_id: + raise ValueError( + "Either model= or model_id= must be provided when creating a new collection" + ) + # Create it + if model_id: + # Resolve alias + model = llm.get_embedding_model(model_id) + self._model = model + model_id = cast(EmbeddingModel, model).model_id + self.id = ( + cast(Table, self.db["collections"]) + .insert( + { + "name": self.name, + "model": model_id, + } + ) + .last_pk + ) + else: + raise self.DoesNotExist(f"Collection '{name}' does not exist") + + def model(self) -> EmbeddingModel: + "Return the embedding model used by this collection" + import llm + + if self._model is None: + self._model = llm.get_embedding_model(self.model_id) + + return cast(EmbeddingModel, self._model) + + def count(self) -> int: + """ + Count the number of items in the collection. + + Returns: + int: Number of items in the collection + """ + return next( + self.db.query( + """ + select count(*) as c from embeddings where collection_id = ( + select id from collections where name = ? + ) + """, + (self.name,), + ) + )["c"] + + def embed( + self, + id: str, + value: Union[str, bytes], + metadata: Optional[Dict[str, Any]] = None, + store: bool = False, + ) -> None: + """ + Embed value and store it in the collection with a given ID. + + Args: + id (str): ID for the value + value (str or bytes): value to be embedded + metadata (dict, optional): Metadata to be stored + store (bool, optional): Whether to store the value in the content or content_blob column + """ + from llm import encode + + content_hash = self.content_hash(value) + if self.db["embeddings"].count_where( + "content_hash = ? and collection_id = ?", [content_hash, self.id] + ): + return + embedding = self.model().embed(value) + cast(Table, self.db["embeddings"]).insert( + { + "collection_id": self.id, + "id": id, + "embedding": encode(embedding), + "content": value if (store and isinstance(value, str)) else None, + "content_blob": value if (store and isinstance(value, bytes)) else None, + "content_hash": content_hash, + "metadata": json.dumps(metadata) if metadata else None, + "updated": int(time.time()), + }, + replace=True, + ) + + def embed_multi( + self, + entries: Iterable[Tuple[str, Union[str, bytes]]], + store: bool = False, + batch_size: int = 100, + ) -> None: + """ + Embed multiple texts and store them in the collection with given IDs. + + Args: + entries (iterable): Iterable of (id: str, text: str) tuples + store (bool, optional): Whether to store the text in the content column + batch_size (int, optional): custom maximum batch size to use + """ + self.embed_multi_with_metadata( + ((id, value, None) for id, value in entries), + store=store, + batch_size=batch_size, + ) + + def embed_multi_with_metadata( + self, + entries: Iterable[Tuple[str, Union[str, bytes], Optional[Dict[str, Any]]]], + store: bool = False, + batch_size: int = 100, + ) -> None: + """ + Embed multiple values along with metadata and store them in the collection with given IDs. + + Args: + entries (iterable): Iterable of (id: str, value: str or bytes, metadata: None or dict) + store (bool, optional): Whether to store the value in the content or content_blob column + batch_size (int, optional): custom maximum batch size to use + """ + import llm + + batch_size = min(batch_size, (self.model().batch_size or batch_size)) + iterator = iter(entries) + collection_id = self.id + while True: + batch = list(islice(iterator, batch_size)) + if not batch: + break + # Calculate hashes first + items_and_hashes = [(item, self.content_hash(item[1])) for item in batch] + # Any of those hashes already exist? + existing_ids = [ + row["id"] + for row in self.db.query( + """ + select id from embeddings + where collection_id = ? and content_hash in ({}) + """.format(",".join("?" for _ in items_and_hashes)), + [collection_id] + + [item_and_hash[1] for item_and_hash in items_and_hashes], + ) + ] + filtered_batch = [item for item in batch if item[0] not in existing_ids] + embeddings = list( + self.model().embed_multi(item[1] for item in filtered_batch) + ) + with self.db.conn: + cast(Table, self.db["embeddings"]).insert_all( + ( + { + "collection_id": collection_id, + "id": id, + "embedding": llm.encode(embedding), + "content": ( + value if (store and isinstance(value, str)) else None + ), + "content_blob": ( + value if (store and isinstance(value, bytes)) else None + ), + "content_hash": self.content_hash(value), + "metadata": json.dumps(metadata) if metadata else None, + "updated": int(time.time()), + } + for (embedding, (id, value, metadata)) in zip( + embeddings, filtered_batch + ) + ), + replace=True, + ) + + def similar_by_vector( + self, + vector: List[float], + number: int = 10, + skip_id: Optional[str] = None, + prefix: Optional[str] = None, + ) -> List[Entry]: + """ + Find similar items in the collection by a given vector. + + Args: + vector (list): Vector to search by + number (int, optional): Number of similar items to return + skip_id (str, optional): An ID to exclude from the results + prefix: (str, optional): Filter results to IDs witih this prefix + + Returns: + list: List of Entry objects + """ + import llm + + def distance_score(other_encoded): + other_vector = llm.decode(other_encoded) + return llm.cosine_similarity(other_vector, vector) + + self.db.register_function(distance_score, replace=True) + + where_bits = ["collection_id = ?"] + where_args = [str(self.id)] + + if prefix: + where_bits.append("id LIKE ? || '%'") + where_args.append(prefix) + + if skip_id: + where_bits.append("id != ?") + where_args.append(skip_id) + + return [ + Entry( + id=row["id"], + score=row["score"], + content=row["content"], + metadata=json.loads(row["metadata"]) if row["metadata"] else None, + ) + for row in self.db.query( + """ + select id, content, metadata, distance_score(embedding) as score + from embeddings + where {where} + order by score desc limit {number} + """.format( + where=" and ".join(where_bits), + number=number, + ), + where_args, + ) + ] + + def similar_by_id( + self, id: str, number: int = 10, prefix: Optional[str] = None + ) -> List[Entry]: + """ + Find similar items in the collection by a given ID. + + Args: + id (str): ID to search by + number (int, optional): Number of similar items to return + prefix: (str, optional): Filter results to IDs with this prefix + + Returns: + list: List of Entry objects + """ + import llm + + matches = list( + self.db["embeddings"].rows_where( + "collection_id = ? and id = ?", (self.id, id) + ) + ) + if not matches: + raise self.DoesNotExist("ID not found") + embedding = matches[0]["embedding"] + comparison_vector = llm.decode(embedding) + return self.similar_by_vector( + comparison_vector, number, skip_id=id, prefix=prefix + ) + + def similar( + self, value: Union[str, bytes], number: int = 10, prefix: Optional[str] = None + ) -> List[Entry]: + """ + Find similar items in the collection by a given value. + + Args: + value (str or bytes): value to search by + number (int, optional): Number of similar items to return + prefix: (str, optional): Filter results to IDs with this prefix + + Returns: + list: List of Entry objects + """ + comparison_vector = self.model().embed(value) + return self.similar_by_vector(comparison_vector, number, prefix=prefix) + + @classmethod + def exists(cls, db: Database, name: str) -> bool: + """ + Does this collection exist in the database? + + Args: + name (str): Name of the collection + """ + rows = list(db["collections"].rows_where("name = ?", [name])) + return bool(rows) + + def delete(self): + """ + Delete the collection and its embeddings from the database + """ + with self.db.conn: + self.db.execute("delete from embeddings where collection_id = ?", [self.id]) + self.db.execute("delete from collections where id = ?", [self.id]) + + @staticmethod + def content_hash(input: Union[str, bytes]) -> bytes: + "Hash content for deduplication. Override to change hashing behavior." + if isinstance(input, str): + input = input.encode("utf8") + return hashlib.md5(input).digest() diff --git a/llm/embeddings_migrations.py b/llm/embeddings_migrations.py new file mode 100644 index 0000000..96444bd --- /dev/null +++ b/llm/embeddings_migrations.py @@ -0,0 +1,89 @@ +from sqlite_migrate import Migrations +import hashlib +import time + +embeddings_migrations = Migrations("llm.embeddings") + + +@embeddings_migrations() +def m001_create_tables(db): + db["collections"].create({"id": int, "name": str, "model": str}, pk="id") + db["collections"].create_index(["name"], unique=True) + db["embeddings"].create( + { + "collection_id": int, + "id": str, + "embedding": bytes, + "content": str, + "metadata": str, + }, + pk=("collection_id", "id"), + ) + + +@embeddings_migrations() +def m002_foreign_key(db): + db["embeddings"].add_foreign_key("collection_id", "collections", "id") + + +@embeddings_migrations() +def m003_add_updated(db): + db["embeddings"].add_column("updated", int) + # Pretty-print the schema + db["embeddings"].transform() + # Assume anything existing was last updated right now + db.execute( + "update embeddings set updated = ? where updated is null", [int(time.time())] + ) + + +@embeddings_migrations() +def m004_store_content_hash(db): + db["embeddings"].add_column("content_hash", bytes) + db["embeddings"].transform( + column_order=( + "collection_id", + "id", + "embedding", + "content", + "content_hash", + "metadata", + "updated", + ) + ) + + # Register functions manually so we can de-register later + def md5(text): + return hashlib.md5(text.encode("utf8")).digest() + + def random_md5(): + return hashlib.md5(str(time.time()).encode("utf8")).digest() + + db.conn.create_function("temp_md5", 1, md5) + db.conn.create_function("temp_random_md5", 0, random_md5) + + with db.conn: + db.execute(""" + update embeddings + set content_hash = temp_md5(content) + where content is not null + """) + db.execute(""" + update embeddings + set content_hash = temp_random_md5() + where content is null + """) + + db["embeddings"].create_index(["content_hash"]) + + # De-register functions + db.conn.create_function("temp_md5", 1, None) + db.conn.create_function("temp_random_md5", 0, None) + + +@embeddings_migrations() +def m005_add_content_blob(db): + db["embeddings"].add_column("content_blob", bytes) + db["embeddings"].transform( + column_order=("collection_id", "id", "embedding", "content", "content_blob") + ) diff --git a/llm/errors.py b/llm/errors.py new file mode 100644 index 0000000..10f50bb --- /dev/null +++ b/llm/errors.py @@ -0,0 +1,6 @@ +class ModelError(Exception): + "Models can raise this error, which will be displayed to the user" + + +class NeedsKeyException(ModelError): + "Model needs an API key which has not been provided" diff --git a/llm/hookspecs.py b/llm/hookspecs.py new file mode 100644 index 0000000..7ab5551 --- /dev/null +++ b/llm/hookspecs.py @@ -0,0 +1,35 @@ +from pluggy import HookimplMarker +from pluggy import HookspecMarker + +hookspec = HookspecMarker("llm") +hookimpl = HookimplMarker("llm") + + +@hookspec +def register_commands(cli): + """Register additional CLI commands, e.g. 'llm mycommand ...'""" + + +@hookspec +def register_models(register, model_aliases): + "Register additional model instances representing LLM models that can be called" + + +@hookspec +def register_embedding_models(register): + "Register additional model instances that can be used for embedding" + + +@hookspec +def register_template_loaders(register): + "Register additional template loaders with prefixes" + + +@hookspec +def register_fragment_loaders(register): + "Register additional fragment loaders with prefixes" + + +@hookspec +def register_tools(register): + "Register functions that can be used as tools by the LLMs" diff --git a/llm/migrations.py b/llm/migrations.py new file mode 100644 index 0000000..985aaa6 --- /dev/null +++ b/llm/migrations.py @@ -0,0 +1,428 @@ +import datetime +from typing import Callable, List + +MIGRATIONS: List[Callable] = [] +migration = MIGRATIONS.append + + +def migrate(db): + ensure_migrations_table(db) + already_applied = {r["name"] for r in db["_llm_migrations"].rows} + for fn in MIGRATIONS: + name = fn.__name__ + if name not in already_applied: + fn(db) + db["_llm_migrations"].insert( + { + "name": name, + "applied_at": str(datetime.datetime.now(datetime.timezone.utc)), + } + ) + already_applied.add(name) + + +def ensure_migrations_table(db): + if not db["_llm_migrations"].exists(): + db["_llm_migrations"].create( + { + "name": str, + "applied_at": str, + }, + pk="name", + ) + + +@migration +def m001_initial(db): + # Ensure the original table design exists, so other migrations can run + if db["log"].exists(): + # It needs to have the chat_id column + if "chat_id" not in db["log"].columns_dict: + db["log"].add_column("chat_id") + return + db["log"].create( + { + "provider": str, + "system": str, + "prompt": str, + "chat_id": str, + "response": str, + "model": str, + "timestamp": str, + } + ) + + +@migration +def m002_id_primary_key(db): + db["log"].transform(pk="id") + + +@migration +def m003_chat_id_foreign_key(db): + db["log"].transform(types={"chat_id": int}) + db["log"].add_foreign_key("chat_id", "log", "id") + + +@migration +def m004_column_order(db): + db["log"].transform( + column_order=( + "id", + "model", + "timestamp", + "prompt", + "system", + "response", + "chat_id", + ) + ) + + +@migration +def m004_drop_provider(db): + db["log"].transform(drop=("provider",)) + + +@migration +def m005_debug(db): + db["log"].add_column("debug", str) + db["log"].add_column("duration_ms", int) + + +@migration +def m006_new_logs_table(db): + columns = db["log"].columns_dict + for column, type in ( + ("options_json", str), + ("prompt_json", str), + ("response_json", str), + ("reply_to_id", int), + ): + # It's possible people running development code like myself + # might have accidentally created these columns already + if column not in columns: + db["log"].add_column(column, type) + + # Use .transform() to rename options and timestamp_utc, and set new order + db["log"].transform( + column_order=( + "id", + "model", + "prompt", + "system", + "prompt_json", + "options_json", + "response", + "response_json", + "reply_to_id", + "chat_id", + "duration_ms", + "timestamp_utc", + ), + rename={ + "timestamp": "timestamp_utc", + "options": "options_json", + }, + ) + + +@migration +def m007_finish_logs_table(db): + db["log"].transform( + drop={"debug"}, + rename={"timestamp_utc": "datetime_utc"}, + drop_foreign_keys=("chat_id",), + ) + with db.conn: + db.execute("alter table log rename to logs") + + +@migration +def m008_reply_to_id_foreign_key(db): + db["logs"].add_foreign_key("reply_to_id", "logs", "id") + + +@migration +def m008_fix_column_order_in_logs(db): + # reply_to_id ended up at the end after foreign key added + db["logs"].transform( + column_order=( + "id", + "model", + "prompt", + "system", + "prompt_json", + "options_json", + "response", + "response_json", + "reply_to_id", + "chat_id", + "duration_ms", + "timestamp_utc", + ), + ) + + +@migration +def m009_delete_logs_table_if_empty(db): + # We moved to a new table design, but we don't delete the table + # if someone has put data in it + if not db["logs"].count: + db["logs"].drop() + + +@migration +def m010_create_new_log_tables(db): + db["conversations"].create( + { + "id": str, + "name": str, + "model": str, + }, + pk="id", + ) + db["responses"].create( + { + "id": str, + "model": str, + "prompt": str, + "system": str, + "prompt_json": str, + "options_json": str, + "response": str, + "response_json": str, + "conversation_id": str, + "duration_ms": int, + "datetime_utc": str, + }, + pk="id", + foreign_keys=(("conversation_id", "conversations", "id"),), + ) + + +@migration +def m011_fts_for_responses(db): + db["responses"].enable_fts(["prompt", "response"], create_triggers=True) + + +@migration +def m012_attachments_tables(db): + db["attachments"].create( + { + "id": str, + "type": str, + "path": str, + "url": str, + "content": bytes, + }, + pk="id", + ) + db["prompt_attachments"].create( + { + "response_id": str, + "attachment_id": str, + "order": int, + }, + foreign_keys=( + ("response_id", "responses", "id"), + ("attachment_id", "attachments", "id"), + ), + pk=("response_id", "attachment_id"), + ) + + +@migration +def m013_usage(db): + db["responses"].add_column("input_tokens", int) + db["responses"].add_column("output_tokens", int) + db["responses"].add_column("token_details", str) + + +@migration +def m014_schemas(db): + db["schemas"].create( + { + "id": str, + "content": str, + }, + pk="id", + ) + db["responses"].add_column("schema_id", str, fk="schemas", fk_col="id") + # Clean up SQL create table indentation + db["responses"].transform() + # These changes may have dropped the FTS configuration, fix that + db["responses"].enable_fts( + ["prompt", "response"], create_triggers=True, replace=True + ) + + +@migration +def m015_fragments_tables(db): + db["fragments"].create( + { + "id": int, + "hash": str, + "content": str, + "datetime_utc": str, + "source": str, + }, + pk="id", + ) + db["fragments"].create_index(["hash"], unique=True) + db["fragment_aliases"].create( + { + "alias": str, + "fragment_id": int, + }, + foreign_keys=(("fragment_id", "fragments", "id"),), + pk="alias", + ) + db["prompt_fragments"].create( + { + "response_id": str, + "fragment_id": int, + "order": int, + }, + foreign_keys=( + ("response_id", "responses", "id"), + ("fragment_id", "fragments", "id"), + ), + pk=("response_id", "fragment_id"), + ) + db["system_fragments"].create( + { + "response_id": str, + "fragment_id": int, + "order": int, + }, + foreign_keys=( + ("response_id", "responses", "id"), + ("fragment_id", "fragments", "id"), + ), + pk=("response_id", "fragment_id"), + ) + + +@migration +def m016_fragments_table_pks(db): + # The same fragment can be attached to a response multiple times + # https://github.com/simonw/llm/issues/863#issuecomment-2781720064 + db["prompt_fragments"].transform(pk=("response_id", "fragment_id", "order")) + db["system_fragments"].transform(pk=("response_id", "fragment_id", "order")) + + +@migration +def m017_tools_tables(db): + db["tools"].create( + { + "id": int, + "hash": str, + "name": str, + "description": str, + "input_schema": str, + }, + pk="id", + ) + db["tools"].create_index(["hash"], unique=True) + # Many-to-many relationship between tools and responses + db["tool_responses"].create( + { + "tool_id": int, + "response_id": str, + }, + foreign_keys=( + ("tool_id", "tools", "id"), + ("response_id", "responses", "id"), + ), + pk=("tool_id", "response_id"), + ) + # tool_calls and tool_results are one-to-many against responses + db["tool_calls"].create( + { + "id": int, + "response_id": str, + "tool_id": int, + "name": str, + "arguments": str, + "tool_call_id": str, + }, + pk="id", + foreign_keys=( + ("response_id", "responses", "id"), + ("tool_id", "tools", "id"), + ), + ) + db["tool_results"].create( + { + "id": int, + "response_id": str, + "tool_id": int, + "name": str, + "output": str, + "tool_call_id": str, + }, + pk="id", + foreign_keys=( + ("response_id", "responses", "id"), + ("tool_id", "tools", "id"), + ), + ) + + +@migration +def m017_tools_plugin(db): + db["tools"].add_column("plugin") + + +@migration +def m018_tool_instances(db): + # Used to track instances of Toolbox classes that may be + # used multiple times by different tools + db["tool_instances"].create( + { + "id": int, + "plugin": str, + "name": str, + "arguments": str, + }, + pk="id", + ) + # We record which instance was used only on the results + db["tool_results"].add_column("instance_id", fk="tool_instances") + + +@migration +def m019_resolved_model(db): + # For models like gemini-1.5-flash-latest where we wish to record + # the resolved model name in addition to the alias + db["responses"].add_column("resolved_model", str) + + +@migration +def m020_tool_results_attachments(db): + db["tool_results_attachments"].create( + { + "tool_result_id": int, + "attachment_id": str, + "order": int, + }, + foreign_keys=( + ("tool_result_id", "tool_results", "id"), + ("attachment_id", "attachments", "id"), + ), + pk=("tool_result_id", "attachment_id"), + ) + + +@migration +def m021_tool_results_exception(db): + db["tool_results"].add_column("exception", str) + + +@migration +def m022_response_reasoning(db): + # Concatenated visible reasoning text emitted during the response. + # NULL/empty when no reasoning was emitted or when the provider + # only reported an opaque token count (the redacted-marker case). + db["responses"].add_column("reasoning", str) diff --git a/llm/models.py b/llm/models.py new file mode 100644 index 0000000..80fb6b1 --- /dev/null +++ b/llm/models.py @@ -0,0 +1,3459 @@ +import asyncio +import base64 +from condense_json import condense_json +import dataclasses +from dataclasses import dataclass, field +import datetime +from .errors import NeedsKeyException +import hashlib +import httpx +from itertools import islice +from pathlib import Path +import re +import time +from types import MethodType +from typing import ( + TYPE_CHECKING, + Any, + AsyncGenerator, + AsyncIterator, + Awaitable, + Callable, + Dict, + Iterable, + Iterator, + List, + Optional, + Set, + Union, + cast, + get_type_hints, +) +from .serialization import ResponseDict + +if TYPE_CHECKING: + from .parts import StreamEvent +from .utils import ( + ensure_fragment, + ensure_tool, + make_schema_id, + mimetype_from_path, + mimetype_from_string, + token_usage_string, + monotonic_ulid, + Fragment, +) +from abc import ABC, abstractmethod +import inspect +import json +from pydantic import BaseModel, ConfigDict, create_model + +CONVERSATION_NAME_LENGTH = 32 + + +@dataclass +class Usage: + "Token usage information from a model response." + + input: Optional[int] = None + output: Optional[int] = None + details: Optional[Dict[str, Any]] = None + + +@dataclass +class Attachment: + "An attachment (image, audio, etc) to include with a prompt." + + type: Optional[str] = None + path: Optional[str] = None + url: Optional[str] = None + content: Optional[bytes] = None + _id: Optional[str] = None + + def id(self): + # Hash of the binary content, or of '{"url": "https://..."}' for URL attachments + if self._id is None: + if self.content: + self._id = hashlib.sha256(self.content).hexdigest() + elif self.path: + self._id = hashlib.sha256(Path(self.path).read_bytes()).hexdigest() + else: + self._id = hashlib.sha256( + json.dumps({"url": self.url}).encode("utf-8") + ).hexdigest() + return self._id + + def resolve_type(self): + "Return the content type, guessing from content if not specified." + if self.type: + return self.type + # Derive it from path or url or content + if self.path: + return mimetype_from_path(self.path) + if self.url: + response = httpx.head(self.url) + response.raise_for_status() + return response.headers.get("content-type") + if self.content: + return mimetype_from_string(self.content) + raise ValueError("Attachment has no type and no content to derive it from") + + def content_bytes(self): + "Return the binary content, reading from path or URL if needed." + content = self.content + if not content: + if self.path: + content = Path(self.path).read_bytes() + elif self.url: + response = httpx.get(self.url) + response.raise_for_status() + content = response.content + return content + + def base64_content(self): + "Return the content as a base64-encoded string." + return base64.b64encode(self.content_bytes()).decode("utf-8") + + def __repr__(self): + info = [f"" + + @classmethod + def from_row(cls, row): + return cls( + _id=row["id"], + type=row["type"], + path=row["path"], + url=row["url"], + content=row["content"], + ) + + +@dataclass +class Tool: + "A tool that can be called by a model." + + name: str + description: Optional[str] = None + input_schema: Dict = field(default_factory=dict) + implementation: Optional[Callable] = None + plugin: Optional[str] = None # plugin tool came from, e.g. 'llm_tools_sqlite' + + def __post_init__(self): + # Convert Pydantic model to JSON schema if needed + self.input_schema = _ensure_dict_schema(self.input_schema) + + def hash(self): + """Hash for tool based on its name, description and input schema (preserving key order)""" + to_hash = { + "name": self.name, + "description": self.description, + "input_schema": self.input_schema, + } + if self.plugin: + to_hash["plugin"] = self.plugin + return hashlib.sha256(json.dumps(to_hash).encode("utf-8")).hexdigest() + + @classmethod + def function(cls, function, name=None, description=None): + """ + Turn a Python function into a Tool object by: + - Extracting the function name + - Using the function docstring for the Tool description + - Building a Pydantic model for inputs by inspecting the function signature + - Building a Pydantic model for the return value by using the function's return annotation + """ + if not name and function.__name__ == "": + raise ValueError( + "Cannot create a Tool from a lambda function without providing name=" + ) + + return cls( + name=name or function.__name__, + description=description or function.__doc__ or None, + input_schema=_get_arguments_input_schema(function, name), + implementation=function, + ) + + +def _get_arguments_input_schema(function, name): + signature = inspect.signature(function) + type_hints = get_type_hints(function) + fields = {} + for param_name, param in signature.parameters.items(): + if param_name in ("self", "llm_tool_call"): + # llm_tool_call is reserved: populated with the ToolCall object + # at execution time, never exposed to the model. + continue + # Determine the type annotation (default to string if missing) + annotated_type = type_hints.get(param_name, str) + + # Handle default value if present; if there's no default, use '...' + if param.default is inspect.Parameter.empty: + fields[param_name] = (annotated_type, ...) + else: + fields[param_name] = (annotated_type, param.default) + + return create_model(f"{name}InputSchema", **fields) + + +def _accepts_llm_tool_call(implementation) -> bool: + try: + signature = inspect.signature(implementation) + except (TypeError, ValueError): + return False + return "llm_tool_call" in signature.parameters + + +def _implementation_arguments(tool: "Tool", tool_call: "ToolCall") -> dict: + """Arguments to invoke a tool implementation with. + + Implementations with an explicit ``llm_tool_call`` parameter receive + the ToolCall object itself - a ``**kwargs`` catch-all does not count. + """ + arguments = dict(tool_call.arguments) + if _accepts_llm_tool_call(tool.implementation): + arguments["llm_tool_call"] = tool_call + return arguments + + +class Toolbox: + name: Optional[str] = None + instance_id: Optional[int] = None + _blocked = ( + "tools", + "add_tool", + "method_tools", + "__init_subclass__", + "prepare", + "prepare_async", + ) + _extra_tools: List[Tool] = [] + _config: Dict[str, Any] = {} + _prepared: bool = False + _async_prepared: bool = False + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + + original_init = cls.__init__ + + def wrapped_init(self, *args, **kwargs): + # Track args/kwargs passed to constructor in self._config + # so we can serialize them to a database entry later on + sig = inspect.signature(original_init) + bound = sig.bind(self, *args, **kwargs) + bound.apply_defaults() + + self._config = { + name: value + for name, value in bound.arguments.items() + if name != "self" + and sig.parameters[name].kind + not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) + } + self._extra_tools = [] + + original_init(self, *args, **kwargs) + + cls.__init__ = wrapped_init + + @classmethod + def method_tools(cls) -> List[Tool]: + tools = [] + for method_name in dir(cls): + if method_name.startswith("_") or method_name in cls._blocked: + continue + method = getattr(cls, method_name) + if callable(method): + tool = Tool.function( + method, + name="{}_{}".format(cls.__name__, method_name), + ) + tools.append(tool) + return tools + + def tools(self) -> Iterable[Tool]: + "Returns an llm.Tool() for each class method, plus any extras registered with add_tool()" + # method_tools() returns unbound methods, we need bound methods here: + for name in dir(self): + if name.startswith("_") or name in self._blocked: + continue + attr = getattr(self, name) + if callable(attr): + tool = Tool.function(attr, name=f"{self.__class__.__name__}_{name}") + tool.plugin = getattr(self, "plugin", None) + yield tool + yield from self._extra_tools + + def add_tool( + self, tool_or_function: Union[Tool, Callable[..., Any]], pass_self: bool = False + ): + "Add a tool to this toolbox" + + def _upgrade(fn): + if pass_self: + return MethodType(fn, self) + return fn + + if isinstance(tool_or_function, Tool): + self._extra_tools.append(tool_or_function) + elif callable(tool_or_function): + self._extra_tools.append(Tool.function(_upgrade(tool_or_function))) + else: + raise ValueError("Tool must be an instance of Tool or a callable function") + + def prepare(self): + """ + Over-ride this to perform setup (and .add_tool() calls) before the toolbox is used. + Implement a similar prepare_async() method for async setup. + """ + pass + + async def prepare_async(self): + """ + Over-ride this to perform async setup (and .add_tool() calls) before the toolbox is used. + """ + pass + + +@dataclass +class ToolCall: + "A request by the model to call a tool." + + name: str + arguments: dict + tool_call_id: Optional[str] = None + + +@dataclass +class ToolResult: + "The result of executing a tool call." + + name: str + output: str + attachments: List[Attachment] = field(default_factory=list) + tool_call_id: Optional[str] = None + instance: Optional[Toolbox] = None + exception: Optional[Exception] = None + + +@dataclass +class ToolOutput: + "Tool functions can return output with extra attachments" + + output: Optional[Union[str, dict, list, bool, int, float]] = None + attachments: List[Attachment] = field(default_factory=list) + + +ToolDef = Union[Tool, Toolbox, Callable[..., Any]] +BeforeCallSync = Callable[[Optional[Tool], ToolCall], None] +AfterCallSync = Callable[[Tool, ToolCall, ToolResult], None] +BeforeCallAsync = Callable[[Optional[Tool], ToolCall], Union[None, Awaitable[None]]] +AfterCallAsync = Callable[[Tool, ToolCall, ToolResult], Union[None, Awaitable[None]]] + + +class CancelToolCall(Exception): + pass + + +class PauseChain(Exception): + """Raise inside a tool implementation to pause the chain. + + Unlike other exceptions - which are converted into error ToolResults + and sent back to the model - PauseChain propagates out of + ``execute_tool_calls()`` and ``chain()``. Before it is re-raised the + framework populates two attributes: + + - ``tool_call``: the ToolCall whose implementation paused + - ``tool_results``: ToolResults of sibling calls in the same batch + that completed + + Concurrent (async) sibling tool calls always run to completion + before the exception propagates; sequential (sync) execution stops + at the paused call, leaving later calls unexecuted so they can + safely run when the chain is resumed. Resume by re-running the + chain with a ``messages=`` history that ends in the unresolved tool + calls. + """ + + def __init__(self, *args): + super().__init__(*args) + self.tool_call: Optional["ToolCall"] = None + self.tool_results: List["ToolResult"] = [] + + +@dataclass +class Prompt: + "The prompt being sent to the model." + + _prompt: Optional[str] + model: "Model" + fragments: Optional[List[Union[str, Fragment]]] + attachments: Optional[List[Attachment]] + _system: Optional[str] + system_fragments: Optional[List[Union[str, Fragment]]] + prompt_json: Optional[str] + schema: Optional[Union[Dict, type[BaseModel]]] + tools: List[Tool] + tool_results: List[ToolResult] + options: "Options" + hide_reasoning: bool + + def __init__( + self, + prompt, + model, + *, + fragments=None, + attachments=None, + system=None, + system_fragments=None, + prompt_json=None, + options=None, + schema=None, + tools=None, + tool_results=None, + messages=None, + hide_reasoning=False, + ): + self._prompt = prompt + self.model = model + self.attachments = list(attachments or []) + self.fragments = fragments or [] + self._system = system + self.system_fragments = system_fragments or [] + self.prompt_json = prompt_json + if schema and not isinstance(schema, dict) and issubclass(schema, BaseModel): + schema = schema.model_json_schema() + self.schema = schema + self.tools = _wrap_tools(tools or []) + self.tool_results = tool_results or [] + self.options = options or {} + self.hide_reasoning = hide_reasoning + # Explicit messages= list, if the caller supplied one. Copied so + # later mutation by the caller doesn't alter the Prompt. + self._explicit_messages = list(messages) if messages is not None else None + + @property + def prompt(self): + "The text of the prompt, with any fragments concatenated." + return "\n".join(self.fragments + ([self._prompt] if self._prompt else [])) + + @property + def system(self): + "The system prompt, with any system fragments concatenated." + return _combine_system(self._system, self.system_fragments) + + @property + def messages(self): + """Canonical list of Message objects for this prompt. + + **Invariant:** this property returns exactly what the model + was (or will be) sent for this turn — the full chain including + any prior conversation history. + + - If ``messages=`` was passed explicitly, it is authoritative: + returned verbatim. Other kwargs (``prompt=``, ``system=``, + ``attachments=``, ``tool_results=``) are ignored for the + messages list (they remain available via ``prompt.prompt``, + ``prompt.system``, etc., for adapters that still read them). + - Otherwise the list is synthesized from the legacy kwargs + (system, tool_results, prompt, attachments), producing just + the current turn — prior history is not folded in, because + no conversation context is reachable here. + + Conversation.prompt / AsyncConversation.prompt / reply() all + pre-compute the full chain and pass it as ``messages=``, so + ``response.prompt.messages`` after those paths is the full + chain. + """ + from .parts import ( + AttachmentPart, + Message, + TextPart, + ToolResultPart, + ) + + if self._explicit_messages is not None: + return list(self._explicit_messages) + + result: List["Message"] = [] + + if self.system: + result.append(Message(role="system", parts=[TextPart(text=self.system)])) + + if self.tool_results: + result.append( + Message( + role="tool", + parts=[ + ToolResultPart( + name=tr.name, + output=tr.output, + tool_call_id=tr.tool_call_id, + ) + for tr in self.tool_results + ], + ) + ) + + user_parts: List[Any] = [] + if self.prompt: + user_parts.append(TextPart(text=self.prompt)) + for att in self.attachments: + user_parts.append(AttachmentPart(attachment=att)) + if user_parts: + result.append(Message(role="user", parts=user_parts)) + + return result + + +def _wrap_tools(tools: List[ToolDef]) -> List[Tool]: + wrapped_tools = [] + for tool in tools: + if isinstance(tool, Tool): + wrapped_tools.append(tool) + elif isinstance(tool, Toolbox): + wrapped_tools.extend(tool.tools()) + elif callable(tool): + wrapped_tools.append(Tool.function(tool)) + else: + raise ValueError(f"Invalid tool: {tool}") + return wrapped_tools + + +def _combine_system(system, system_fragments): + "Concatenate the system prompt and any system fragments into one string." + bits = [ + bit.strip() + for bit in ((system_fragments or []) + [system or ""]) + if bit.strip() + ] + return "\n\n".join(bits) + + +def _merge_options(options: Optional[dict], kwargs: dict) -> dict: + if not options: + return kwargs + overlap = set(options) & set(kwargs) + if overlap: + raise TypeError( + "Got values for these options both in options= and as keyword " + "arguments: {}".format(sorted(overlap)) + ) + return {**options, **kwargs} + + +@dataclass +class _BaseConversation: + model: "_BaseModel" + id: str = field(default_factory=lambda: str(monotonic_ulid()).lower()) + name: Optional[str] = None + responses: List["_BaseResponse"] = field(default_factory=list) + tools: Optional[List[ToolDef]] = None + chain_limit: Optional[int] = None + + @classmethod + @abstractmethod + def from_row(cls, row: Any) -> "_BaseConversation": + raise NotImplementedError + + def _build_full_chain( + self, + prompt: Optional[str], + attachments, + tool_results, + explicit_messages, + system=None, + system_fragments=None, + ) -> List[Any]: + """Build the full message chain for the next turn. + + Uses the last response's stored prompt chain to recover prior + history, then appends the new turn's content (explicit messages + first, or synthesized from prompt/attachments/tool_results). + + Returns the list that should be passed as ``messages=`` to the + Prompt constructor so that ``response.prompt.messages`` equals + exactly what the model sees. + + If ``explicit_messages`` is provided, the caller has opted out + of history reconstruction and the list is used as-is. + """ + from .parts import ( + AttachmentPart, + Message, + TextPart, + ToolResultPart, + ) + + if explicit_messages is not None: + return list(explicit_messages) + + chain: List[Any] = [] + if self.responses: + last = self.responses[-1] + # last.prompt.messages already contains the full input chain + # under the invariant, so use the last response only and then + # append that response's structured output. + chain.extend(last.prompt.messages) + chain.extend(last._messages_now()) + else: + # Start with the system prompt as the first message so adapters + # that build from prompt.messages see it. On later turns it + # is already carried forward in last.prompt.messages. + system_text = _combine_system(system, system_fragments) + if system_text: + chain.append(Message(role="system", parts=[TextPart(text=system_text)])) + + # Append the new turn's input + if tool_results: + chain.append( + Message( + role="tool", + parts=[ + ToolResultPart( + name=tr.name, + output=tr.output, + tool_call_id=tr.tool_call_id, + ) + for tr in tool_results + ], + ) + ) + + user_parts: List[Any] = [] + if prompt: + user_parts.append(TextPart(text=prompt)) + for att in attachments or []: + user_parts.append(AttachmentPart(attachment=att)) + if user_parts: + chain.append(Message(role="user", parts=user_parts)) + + return chain + + +@dataclass +class Conversation(_BaseConversation): + before_call: Optional[BeforeCallSync] = None + after_call: Optional[AfterCallSync] = None + + def prompt( + self, + prompt: Optional[str] = None, + *, + fragments: Optional[List[Union[str, Fragment]]] = None, + attachments: Optional[List[Attachment]] = None, + system: Optional[str] = None, + schema: Optional[Union[dict, type[BaseModel]]] = None, + tools: Optional[List[ToolDef]] = None, + tool_results: Optional[List[ToolResult]] = None, + system_fragments: Optional[List[Union[str, Fragment]]] = None, + messages: Optional[List[Any]] = None, + stream: bool = True, + key: Optional[str] = None, + options: Optional[dict] = None, + hide_reasoning: bool = False, + **kwargs, + ) -> "Response": + merged = _merge_options(options, kwargs) + # Build the authoritative chain so response.prompt.messages + # equals exactly what the model sees for this turn. + chain = self._build_full_chain( + prompt=prompt, + attachments=attachments, + tool_results=tool_results, + explicit_messages=messages, + system=system, + system_fragments=system_fragments, + ) + return Response( + Prompt( + prompt, + model=self.model, + fragments=fragments, + attachments=attachments, + system=system, + schema=schema, + tools=tools or self.tools, + tool_results=tool_results, + system_fragments=system_fragments, + messages=chain, + options=self.model.Options(**merged), + hide_reasoning=hide_reasoning, + ), + self.model, + stream, + conversation=self, + key=key, + ) + + def chain( + self, + prompt: Optional[str] = None, + *, + fragments: Optional[List[str]] = None, + attachments: Optional[List[Attachment]] = None, + system: Optional[str] = None, + system_fragments: Optional[List[str]] = None, + messages: Optional[List[Any]] = None, + stream: bool = True, + schema: Optional[Union[dict, type[BaseModel]]] = None, + tools: Optional[List[ToolDef]] = None, + tool_results: Optional[List[ToolResult]] = None, + chain_limit: Optional[int] = None, + before_call: Optional[BeforeCallSync] = None, + after_call: Optional[AfterCallSync] = None, + key: Optional[str] = None, + options: Optional[dict] = None, + hide_reasoning: bool = False, + ) -> "ChainResponse": + self.model._validate_attachments(attachments) + # Parity with Conversation.prompt: pre-bake the full chain so + # response.prompt.messages is authoritative for the first turn + # of the chain loop. Subsequent tool-result turns extend the + # chain via _chain_for_tool_results. + chain_messages = self._build_full_chain( + prompt=prompt, + attachments=attachments, + tool_results=tool_results, + explicit_messages=messages, + system=system, + system_fragments=system_fragments, + ) + return ChainResponse( + Prompt( + prompt, + fragments=fragments, + attachments=attachments, + system=system, + schema=schema, + tools=tools or self.tools, + tool_results=tool_results, + system_fragments=system_fragments, + messages=chain_messages, + model=self.model, + options=self.model.Options(**(options or {})), + hide_reasoning=hide_reasoning, + ), + model=self.model, + stream=stream, + conversation=self, + key=key, + before_call=before_call or self.before_call, + after_call=after_call or self.after_call, + chain_limit=chain_limit if chain_limit is not None else self.chain_limit, + ) + + @classmethod + def from_row(cls, row): + from llm import get_model + + return cls( + model=get_model(row["model"]), + id=row["id"], + name=row["name"], + ) + + def __repr__(self): + count = len(self.responses) + s = "s" if count == 1 else "" + return f"<{self.__class__.__name__}: {self.id} - {count} response{s}" + + +@dataclass +class AsyncConversation(_BaseConversation): + before_call: Optional[BeforeCallAsync] = None + after_call: Optional[AfterCallAsync] = None + + def chain( + self, + prompt: Optional[str] = None, + *, + fragments: Optional[List[str]] = None, + attachments: Optional[List[Attachment]] = None, + system: Optional[str] = None, + system_fragments: Optional[List[str]] = None, + messages: Optional[List[Any]] = None, + stream: bool = True, + schema: Optional[Union[dict, type[BaseModel]]] = None, + tools: Optional[List[ToolDef]] = None, + tool_results: Optional[List[ToolResult]] = None, + chain_limit: Optional[int] = None, + before_call: Optional[BeforeCallAsync] = None, + after_call: Optional[AfterCallAsync] = None, + key: Optional[str] = None, + options: Optional[dict] = None, + hide_reasoning: bool = False, + ) -> "AsyncChainResponse": + self.model._validate_attachments(attachments) + chain_messages = self._build_full_chain( + prompt=prompt, + attachments=attachments, + tool_results=tool_results, + explicit_messages=messages, + system=system, + system_fragments=system_fragments, + ) + return AsyncChainResponse( + Prompt( + prompt, + fragments=fragments, + attachments=attachments, + system=system, + schema=schema, + tools=tools or self.tools, + tool_results=tool_results, + system_fragments=system_fragments, + messages=chain_messages, + model=self.model, + options=self.model.Options(**(options or {})), + hide_reasoning=hide_reasoning, + ), + model=self.model, + stream=stream, + conversation=self, + key=key, + before_call=before_call or self.before_call, + after_call=after_call or self.after_call, + chain_limit=chain_limit if chain_limit is not None else self.chain_limit, + ) + + def prompt( + self, + prompt: Optional[str] = None, + *, + fragments: Optional[List[str]] = None, + attachments: Optional[List[Attachment]] = None, + system: Optional[str] = None, + schema: Optional[Union[dict, type[BaseModel]]] = None, + tools: Optional[List[ToolDef]] = None, + tool_results: Optional[List[ToolResult]] = None, + system_fragments: Optional[List[str]] = None, + messages: Optional[List[Any]] = None, + stream: bool = True, + key: Optional[str] = None, + options: Optional[dict] = None, + hide_reasoning: bool = False, + **kwargs, + ) -> "AsyncResponse": + merged = _merge_options(options, kwargs) + chain = self._build_full_chain( + prompt=prompt, + attachments=attachments, + tool_results=tool_results, + explicit_messages=messages, + system=system, + system_fragments=system_fragments, + ) + return AsyncResponse( + Prompt( + prompt, + model=self.model, + fragments=fragments, + attachments=attachments, + system=system, + schema=schema, + tools=tools, + tool_results=tool_results, + system_fragments=system_fragments, + messages=chain, + options=self.model.Options(**merged), + hide_reasoning=hide_reasoning, + ), + self.model, + stream, + conversation=self, + key=key, + ) + + def to_sync_conversation(self): + return Conversation( + model=self.model, + id=self.id, + name=self.name, + responses=[], # Because we only use this in logging + tools=self.tools, + chain_limit=self.chain_limit, + ) + + @classmethod + def from_row(cls, row): + from llm import get_async_model + + return cls( + model=get_async_model(row["model"]), + id=row["id"], + name=row["name"], + ) + + def __repr__(self): + count = len(self.responses) + s = "s" if count == 1 else "" + return f"<{self.__class__.__name__}: {self.id} - {count} response{s}" + + +FRAGMENT_SQL = """ +select + 'prompt' as fragment_type, + fragments.content, + pf."order" as ord +from prompt_fragments pf +join fragments on pf.fragment_id = fragments.id +where pf.response_id = :response_id +union all +select + 'system' as fragment_type, + fragments.content, + sf."order" as ord +from system_fragments sf +join fragments on sf.fragment_id = fragments.id +where sf.response_id = :response_id +order by fragment_type desc, ord asc; +""" + + +class _BaseResponse: + """Base response class shared between sync and async responses""" + + id: str + prompt: "Prompt" + stream: bool + resolved_model: Optional[str] = None + conversation: Optional["_BaseConversation"] = None + _key: Optional[str] = None + _tool_calls: List[ToolCall] = [] + + def __init__( + self, + prompt: Prompt, + model: "_BaseModel", + stream: bool, + conversation: Optional[_BaseConversation] = None, + key: Optional[str] = None, + ): + self.id = str(monotonic_ulid()).lower() + self.prompt = prompt + self._prompt_json = None + self.model = model + self.stream = stream + self._key = key + self._chunks: List[str] = [] + # Every StreamEvent ever yielded by execute(), in order. Plain + # str yields are wrapped as text events (with part_index resolved + # by _resolve_part_index) so this buffer is the single source of + # truth for replay and for assembling response.messages. + self._stream_events: List[Any] = [] + # Auto-allocator state for resolving StreamEvent.part_index=None. + # Plugins yield events with part_index=None (the default) and + # the framework assigns concrete integers based on context: + # consecutive same-family text/reasoning events concatenate, + # tool calls group by tool_call_id, and tool_result is always + # its own part. _auto_index_max tracks the highest index seen + # (explicit or allocated); _auto_last_index / _auto_last_family + # remember the previously-resolved event so same-family runs + # share an index; _auto_tool_id_to_index maps known tool ids to + # their assigned index for parallel-tool-call grouping. + self._auto_index_max: int = -1 + self._auto_last_index: Optional[int] = None + self._auto_last_family: Optional[str] = None + self._auto_tool_id_to_index: Dict[str, int] = {} + self._done = False + self._tool_calls: List[ToolCall] = [] + self.response_json: Optional[Dict[str, Any]] = None + self.conversation = conversation + self.attachments: List[Attachment] = [] + self._start: Optional[float] = None + self._end: Optional[float] = None + self._start_utcnow: Optional[datetime.datetime] = None + self.input_tokens: Optional[int] = None + self.output_tokens: Optional[int] = None + self.token_details: Optional[dict] = None + self.done_callbacks: List[Callable] = [] + + if self.prompt.schema and not self.model.supports_schema: + raise ValueError(f"{self.model} does not support schemas") + + if self.prompt.tools and not self.model.supports_tools: + raise ValueError(f"{self.model} does not support tools") + + def _messages_now(self) -> List[Any]: + """Assemble messages assuming the response is already drained. + + Public ``messages()`` forces / awaits first, then delegates here. + Internal sync paths (``_response_to_dict``, + ``_chain_for_tool_results``) call this directly so they don't + have to await on async responses. + """ + from .parts import Message + + loaded = getattr(self, "_loaded_messages", None) + if loaded is not None: + return list(loaded) + parts = self._build_parts() + if not parts: + return [] + return [Message(role="assistant", parts=parts)] + + @staticmethod + def _event_family(event_type: str) -> str: + if event_type in ("tool_call_name", "tool_call_args"): + return "tool_call" + return event_type + + def _resolve_part_index(self, event): + """Mutate event.part_index in place when the plugin left it None. + + Resolution rules: consecutive same-family text/reasoning events + share an index; tool-call events are grouped by tool_call_id; + tool_result always allocates a fresh index. Explicit indices + pass through but update the allocator's bookkeeping so future + None resolutions avoid collisions. + """ + fam = self._event_family(event.type) + + if event.part_index is not None: + if event.part_index > self._auto_index_max: + self._auto_index_max = event.part_index + if ( + event.type in ("tool_call_name", "tool_call_args") + and event.tool_call_id + ): + self._auto_tool_id_to_index[event.tool_call_id] = event.part_index + self._auto_last_index = event.part_index + self._auto_last_family = fam + return + + if event.type in ("tool_call_name", "tool_call_args"): + if event.tool_call_id: + existing = self._auto_tool_id_to_index.get(event.tool_call_id) + if existing is not None: + event.part_index = existing + self._auto_last_index = existing + self._auto_last_family = "tool_call" + return + self._auto_index_max += 1 + new_idx = self._auto_index_max + self._auto_tool_id_to_index[event.tool_call_id] = new_idx + event.part_index = new_idx + self._auto_last_index = new_idx + self._auto_last_family = "tool_call" + return + # No tool_call_id — providers like Gemini omit the id on + # parallel tool calls. tool_call_args events glue onto the + # most recent tool-call index; a fresh tool_call_name + # always starts a new part (otherwise N parallel tool calls + # collapse into one with concatenated names and args). + if ( + event.type == "tool_call_args" + and self._auto_last_family == "tool_call" + and self._auto_last_index is not None + ): + event.part_index = self._auto_last_index + return + self._auto_index_max += 1 + new_idx = self._auto_index_max + event.part_index = new_idx + self._auto_last_index = new_idx + self._auto_last_family = "tool_call" + return + + if event.type == "tool_result": + self._auto_index_max += 1 + new_idx = self._auto_index_max + event.part_index = new_idx + self._auto_last_index = new_idx + self._auto_last_family = "tool_result" + return + + # text / reasoning: same family as previous → reuse, else new. + if self._auto_last_family == fam and self._auto_last_index is not None: + event.part_index = self._auto_last_index + return + self._auto_index_max += 1 + new_idx = self._auto_index_max + event.part_index = new_idx + self._auto_last_index = new_idx + self._auto_last_family = fam + + def _process_chunk(self, chunk): + """Normalize a chunk from execute() into a StreamEvent and return + the text str (or None) that __iter__ should yield. + + Plain str yields from legacy plugins are wrapped as text events + with an auto-allocated part_index. Side effects: populates + self._stream_events and self._chunks. + """ + from .parts import StreamEvent + + if isinstance(chunk, StreamEvent): + self._resolve_part_index(chunk) + self._stream_events.append(chunk) + if chunk.type == "text": + self._chunks.append(chunk.chunk) + return chunk.chunk + return None + # Legacy plain-str plugin. + event = StreamEvent(type="text", chunk=chunk) + self._resolve_part_index(event) + self._stream_events.append(event) + self._chunks.append(chunk) + return chunk + + def _build_parts(self) -> List[Any]: + """Assemble Part objects from the accumulated stream events. + + Events sharing a part_index group into one Part. Mixing + families (text vs tool_call vs reasoning vs tool_result) at the + same index is a plugin bug — raises ValueError instead of + silently dropping content. + + Fallback: when no stream events were recorded (response was + rehydrated from SQLite via ``from_row``), synthesize a + TextPart from ``self._chunks`` plus any ``self._tool_calls`` + restored by the row loader. Reasoning signatures are not + recoverable from SQLite in this fallback — use + ``response.to_dict()`` / ``Response.from_dict()`` for + structure-preserving persistence. + """ + from .parts import ( + ReasoningPart, + TextPart, + ToolCallPart, + ToolResultPart, + ) + + if not self._stream_events: + # Rehydrated-from-SQLite path: assemble from _chunks + + # _tool_calls so response.messages isn't empty after + # from_row, and Conversation.prompt-built chains include + # the assistant turn on follow-up calls. + fallback_parts: List[Any] = [] + text = "".join(self._chunks) + if text: + fallback_parts.append(TextPart(text=text)) + for tc in self._tool_calls: + fallback_parts.append( + ToolCallPart( + name=tc.name, + arguments=tc.arguments or {}, + tool_call_id=tc.tool_call_id, + ) + ) + return fallback_parts + + # Group events by their (resolved) part_index, preserving the + # order in which each index was first seen. Then build one Part + # per group. This handles non-adjacent same-index events (e.g. + # text → tool_call → text where the plugin pinned both text + # bursts to part_index=0) by merging them into one Part. + groups: Dict[int, List[Any]] = {} + order: List[int] = [] + for event in self._stream_events: + pi = event.part_index + if pi not in groups: + groups[pi] = [] + order.append(pi) + groups[pi].append(event) + + parts: List[Any] = [] + for pi in order: + evs = groups[pi] + fam_first = self._event_family(evs[0].type) + for e in evs: + if self._event_family(e.type) != fam_first: + raise ValueError( + f"StreamEvent type {e.type!r} is incompatible with " + f"prior type at part_index={pi}. " + "Allocate a new part_index for a different content type." + ) + + pm_merged: Optional[Dict[str, Any]] = None + for e in evs: + if e.provider_metadata: + merged = dict(pm_merged) if pm_merged else {} + for k, v in e.provider_metadata.items(): + merged[k] = v + pm_merged = merged + + if fam_first == "text": + text = "".join(e.chunk for e in evs) + if text: + parts.append(TextPart(text=text, provider_metadata=pm_merged)) + elif fam_first == "reasoning": + text = "".join(e.chunk for e in evs) + redacted = any(e.redacted for e in evs) + if text or redacted: + parts.append( + ReasoningPart( + text=text, + redacted=redacted, + provider_metadata=pm_merged, + ) + ) + elif fam_first == "tool_call": + tool_name = "".join(e.chunk for e in evs if e.type == "tool_call_name") + args_str = "".join(e.chunk for e in evs if e.type == "tool_call_args") + try: + arguments = json.loads(args_str) if args_str else {} + except json.JSONDecodeError: + arguments = {"_raw": args_str} + tool_call_id = next( + (e.tool_call_id for e in evs if e.tool_call_id), None + ) + server_executed = any(e.server_executed for e in evs) + parts.append( + ToolCallPart( + name=tool_name, + arguments=arguments, + tool_call_id=tool_call_id, + server_executed=server_executed, + provider_metadata=pm_merged, + ) + ) + elif fam_first == "tool_result": + tool_result_name = next((e.tool_name for e in evs if e.tool_name), "") + tool_call_id = next( + (e.tool_call_id for e in evs if e.tool_call_id), None + ) + server_executed = any(e.server_executed for e in evs) + parts.append( + ToolResultPart( + name=tool_result_name, + output="".join(e.chunk for e in evs), + tool_call_id=tool_call_id, + server_executed=server_executed, + provider_metadata=pm_merged, + ) + ) + + # Merge in any tool calls registered via add_tool_call() that the + # plugin didn't also emit as StreamEvents. Dedup by tool_call_id so + # plugins using both APIs in tandem don't double-count. + seen_ids = { + p.tool_call_id + for p in parts + if isinstance(p, ToolCallPart) and p.tool_call_id is not None + } + for tc in self._tool_calls: + if tc.tool_call_id is not None and tc.tool_call_id in seen_ids: + continue + parts.append( + ToolCallPart( + name=tc.name, + arguments=tc.arguments or {}, + tool_call_id=tc.tool_call_id, + ) + ) + + # Hoist redacted reasoning Parts to the start of the assembled + # message. Plugins typically emit them late (when usage arrives + # in the final chunk), but UIs render reasoning before content, + # so the framework reorders. Relative order among redacted + # Parts is preserved. + redacted_parts = [ + p for p in parts if isinstance(p, ReasoningPart) and p.redacted + ] + if redacted_parts: + other_parts = [ + p for p in parts if not (isinstance(p, ReasoningPart) and p.redacted) + ] + parts = redacted_parts + other_parts + + return parts + + def add_tool_call(self, tool_call: ToolCall): + if tool_call.tool_call_id is None: + # Guarantee every locally-executable tool call has a unique id. + # Some providers never supply one, which otherwise forces every + # consumer correlating calls with results (or keying external + # state on a call) to invent fallback matching schemes. + tool_call = dataclasses.replace( + tool_call, + tool_call_id="tc_{}".format(str(monotonic_ulid()).lower()), + ) + self._tool_calls.append(tool_call) + + def set_usage( + self, + *, + input: Optional[int] = None, + output: Optional[int] = None, + details: Optional[dict] = None, + ): + self.input_tokens = input + self.output_tokens = output + self.token_details = details + + def set_resolved_model(self, model_id: str): + self.resolved_model = model_id + + @classmethod + def from_row(cls, db, row, _async=False): + from llm import get_model, get_async_model + + if _async: + model = get_async_model(row["model"]) + else: + model = get_model(row["model"]) + + # Schema + schema = None + if row["schema_id"]: + schema = json.loads(db["schemas"].get(row["schema_id"])["content"]) + + # Tool definitions and results for prompt + tools = [ + Tool( + name=tool_row["name"], + description=tool_row["description"], + input_schema=json.loads(tool_row["input_schema"]), + # In this case we don't have a reference to the actual Python code + # but that's OK, we should not need it for prompts deserialized from DB + implementation=None, + plugin=tool_row["plugin"], + ) + for tool_row in db.query( + """ + select tools.* from tools + join tool_responses on tools.id = tool_responses.tool_id + where tool_responses.response_id = ? + """, + [row["id"]], + ) + ] + tool_results = [ + ToolResult( + name=tool_results_row["name"], + output=tool_results_row["output"], + tool_call_id=tool_results_row["tool_call_id"], + ) + for tool_results_row in db.query( + """ + select * from tool_results + where response_id = ? + """, + [row["id"]], + ) + ] + + all_fragments = list(db.query(FRAGMENT_SQL, {"response_id": row["id"]})) + fragments = [ + row["content"] for row in all_fragments if row["fragment_type"] == "prompt" + ] + system_fragments = [ + row["content"] for row in all_fragments if row["fragment_type"] == "system" + ] + response = cls( + model=model, + prompt=Prompt( + prompt=row["prompt"], + model=model, + fragments=fragments, + attachments=[], + system=row["system"], + schema=schema, + tools=tools, + tool_results=tool_results, + system_fragments=system_fragments, + options=model.Options(**json.loads(row["options_json"])), + ), + stream=False, + ) + prompt_json = json.loads(row["prompt_json"] or "null") + response.id = row["id"] + response._prompt_json = prompt_json + response.response_json = json.loads(row["response_json"] or "null") + response._done = True + response._chunks = [row["response"]] + # Attachments + response.attachments = [ + Attachment.from_row(attachment_row) + for attachment_row in db.query( + """ + select attachments.* from attachments + join prompt_attachments on attachments.id = prompt_attachments.attachment_id + where prompt_attachments.response_id = ? + order by prompt_attachments."order" + """, + [row["id"]], + ) + ] + # Tool calls + response._tool_calls = [ + ToolCall( + name=tool_row["name"], + arguments=json.loads(tool_row["arguments"]), + tool_call_id=tool_row["tool_call_id"], + ) + for tool_row in db.query( + """ + select * from tool_calls + where response_id = ? + order by tool_call_id + """, + [row["id"]], + ) + ] + + return response + + def token_usage(self) -> str: + return token_usage_string( + self.input_tokens, self.output_tokens, self.token_details + ) + + def log_to_db(self, db): + conversation = self.conversation + if not conversation: + conversation = Conversation(model=self.model) + db["conversations"].insert( + { + "id": conversation.id, + "name": _conversation_name( + self.prompt.prompt or self.prompt.system or "" + ), + "model": conversation.model.model_id, + }, + ignore=True, + ) + schema_id = None + if self.prompt.schema: + schema_id, schema_json = make_schema_id(self.prompt.schema) + db["schemas"].insert({"id": schema_id, "content": schema_json}, ignore=True) + + response_id = self.id + replacements = {} + # Include replacements from previous responses + for previous_response in conversation.responses[:-1]: + for fragment in (previous_response.prompt.fragments or []) + ( + previous_response.prompt.system_fragments or [] + ): + fragment_id = ensure_fragment(db, fragment) + replacements[f"f:{fragment_id}"] = fragment + replacements[f"r:{previous_response.id}"] = ( + previous_response.text_or_raise() + ) + + for i, fragment in enumerate(self.prompt.fragments): + fragment_id = ensure_fragment(db, fragment) + replacements[f"f{fragment_id}"] = fragment + db["prompt_fragments"].insert( + { + "response_id": response_id, + "fragment_id": fragment_id, + "order": i, + }, + ) + for i, fragment in enumerate(self.prompt.system_fragments): + fragment_id = ensure_fragment(db, fragment) + replacements[f"f{fragment_id}"] = fragment + db["system_fragments"].insert( + { + "response_id": response_id, + "fragment_id": fragment_id, + "order": i, + }, + ) + + response_text = self.text_or_raise() + replacements[f"r:{response_id}"] = response_text + # Concatenate visible reasoning text from the assembled + # ReasoningPart entries; redacted markers contribute nothing. + from .parts import ReasoningPart + + reasoning_text = "".join( + p.text + for m in self._messages_now() + for p in m.parts + if isinstance(p, ReasoningPart) and p.text + ) + json_data = self.json() + + response = { + "id": response_id, + "model": self.model.model_id, + "prompt": self.prompt._prompt, + "system": self.prompt._system, + "prompt_json": condense_json(self._prompt_json, replacements), + "options_json": { + key: value + for key, value in dict(self.prompt.options).items() + if value is not None + }, + "response": response_text, + "reasoning": reasoning_text or None, + "response_json": condense_json(json_data, replacements), + "conversation_id": conversation.id, + "duration_ms": self.duration_ms(), + "datetime_utc": self.datetime_utc(), + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "token_details": ( + json.dumps(self.token_details) if self.token_details else None + ), + "schema_id": schema_id, + "resolved_model": self.resolved_model, + } + db["responses"].insert(response) + + # Persist any attachments - loop through with index + for index, attachment in enumerate(self.prompt.attachments): + attachment_id = attachment.id() + db["attachments"].insert( + { + "id": attachment_id, + "type": attachment.resolve_type(), + "path": attachment.path, + "url": attachment.url, + "content": attachment.content, + }, + replace=True, + ) + db["prompt_attachments"].insert( + { + "response_id": response_id, + "attachment_id": attachment_id, + "order": index, + }, + ) + + # Persist any tools, tool calls and tool results + tool_ids_by_name = {} + for tool in self.prompt.tools: + tool_id = ensure_tool(db, tool) + tool_ids_by_name[tool.name] = tool_id + db["tool_responses"].insert( + { + "tool_id": tool_id, + "response_id": response_id, + } + ) + for tool_call in self.tool_calls(): # TODO Should be _or_raise() + db["tool_calls"].insert( + { + "response_id": response_id, + "tool_id": tool_ids_by_name.get(tool_call.name) or None, + "name": tool_call.name, + "arguments": json.dumps(tool_call.arguments), + "tool_call_id": tool_call.tool_call_id, + } + ) + for tool_result in self.prompt.tool_results: + instance_id = None + if tool_result.instance: + try: + if not tool_result.instance.instance_id: + tool_result.instance.instance_id = ( + db["tool_instances"] + .insert( + { + "plugin": tool.plugin, + "name": tool.name.split("_")[0], + "arguments": json.dumps( + tool_result.instance._config + ), + } + ) + .last_pk + ) + instance_id = tool_result.instance.instance_id + except AttributeError: + pass + tool_result_id = ( + db["tool_results"] + .insert( + { + "response_id": response_id, + "tool_id": tool_ids_by_name.get(tool_result.name) or None, + "name": tool_result.name, + "output": tool_result.output, + "tool_call_id": tool_result.tool_call_id, + "instance_id": instance_id, + "exception": ( + ( + "{}: {}".format( + tool_result.exception.__class__.__name__, + str(tool_result.exception), + ) + ) + if tool_result.exception + else None + ), + } + ) + .last_pk + ) + # Persist attachments for tool results + for index, attachment in enumerate(tool_result.attachments): + attachment_id = attachment.id() + db["attachments"].insert( + { + "id": attachment_id, + "type": attachment.resolve_type(), + "path": attachment.path, + "url": attachment.url, + "content": attachment.content, + }, + replace=True, + ) + db["tool_results_attachments"].insert( + { + "tool_result_id": tool_result_id, + "attachment_id": attachment_id, + "order": index, + }, + ) + + +def _response_to_dict(response: "_BaseResponse") -> ResponseDict: + """Shared serializer for Response.to_dict / AsyncResponse.to_dict. + + The output is a JSON-safe dict — store it anywhere (file, Redis, + Postgres, HTTP body) and round-trip via Response.from_dict or + AsyncResponse.from_dict. + """ + options = { + key: value + for key, value in dict(response.prompt.options).items() + if value is not None + } + payload: Dict[str, Any] = { + "model": response.model.model_id, + "prompt": { + "messages": [m.to_dict() for m in response.prompt.messages], + }, + "messages": [m.to_dict() for m in response._messages_now()], + } + if options: + payload["prompt"]["options"] = options + if response.prompt._system: + payload["prompt"]["system"] = response.prompt._system + # Optional audit fields — helpful for debugging, not needed for reply(). + if response.id: + payload["id"] = response.id + if response._done: + if response.input_tokens is not None or response.output_tokens is not None: + usage: Dict[str, Any] = {} + if response.input_tokens is not None: + usage["input"] = response.input_tokens + if response.output_tokens is not None: + usage["output"] = response.output_tokens + if response.token_details is not None: + usage["details"] = response.token_details + payload["usage"] = usage + if response._start_utcnow is not None: + payload["datetime_utc"] = response._start_utcnow.isoformat() + return cast(ResponseDict, payload) + + +def _response_from_dict( + data: ResponseDict, + cls, + *, + model=None, + async_: bool = False, +) -> "_BaseResponse": + """Shared deserializer for Response.from_dict / AsyncResponse.from_dict.""" + from .parts import Message + + if model is None: + from llm import get_async_model, get_model + + getter = get_async_model if async_ else get_model + model = getter(data["model"]) + + prompt_data = data.get("prompt", {}) + input_messages = [Message.from_dict(m) for m in prompt_data.get("messages", [])] + output_messages = [Message.from_dict(m) for m in data.get("messages", [])] + + options_kwargs = prompt_data.get("options") or {} + system = prompt_data.get("system") + + prompt = Prompt( + None, + model=model, + messages=input_messages, + system=system, + options=model.Options(**options_kwargs), + ) + response = cls(prompt, model=model, stream=False) + # Preserve id for audit continuity. + if "id" in data: + response.id = data["id"] + # Rebuild chunks from the assistant's text parts so response.text() + # works without re-running the assembler. + from .parts import TextPart + + response._chunks = [ + p.text + for m in output_messages + for p in m.parts + if isinstance(p, TextPart) and p.text + ] + # Stash the structured output so response.messages returns the + # full picture (reasoning, tool calls, signatures) without needing + # a StreamEvent replay. + response._loaded_messages = output_messages + response._done = True + # Restore usage if present. + usage = data.get("usage") + if usage: + response.input_tokens = usage.get("input") + response.output_tokens = usage.get("output") + response.token_details = usage.get("details") + return response + + +class Response(_BaseResponse): + "Sync response from a model." + + model: "Model" + conversation: Optional["Conversation"] = None + + def reply( + self, + prompt: Optional[str] = None, + *, + messages: Optional[List[Any]] = None, + tool_results: Optional[List[ToolResult]] = None, + options: Optional[dict] = None, + **kwargs, + ) -> "Response": + """Continue the conversation from this response. + + Builds the next turn's chain as + ``self.prompt.messages + self.messages + [tool_message] + + [user(prompt)] + messages`` and calls + ``self.model.prompt(messages=chain, ...)``. + + If this response made tool calls and ``tool_results=`` is not + passed, ``reply()`` runs ``self.execute_tool_calls()`` + automatically and threads the results into the chain. Pass an + explicit ``tool_results=`` list (e.g. results you mutated, or + synthetic ones for testing) to skip auto-execution. + """ + from .parts import Message, TextPart, ToolResultPart + + self._force() + if tool_results is None and self._tool_calls: + tool_results = self.execute_tool_calls() + # Forward original tools so the next turn can call them again + # (mirrors Conversation.prompt's `tools or self.tools` rule). + if "tools" not in kwargs and self.prompt.tools: + kwargs["tools"] = self.prompt.tools + chain: List[Any] = list(self.prompt.messages) + list(self._messages_now()) + if tool_results: + chain.append( + Message( + role="tool", + parts=[ + ToolResultPart( + name=tr.name, + output=tr.output, + tool_call_id=tr.tool_call_id, + ) + for tr in tool_results + ], + ) + ) + if prompt: + chain.append(Message(role="user", parts=[TextPart(text=prompt)])) + if messages: + chain.extend(messages) + return self.model.prompt(messages=chain, options=options, **kwargs) + + def to_dict(self) -> ResponseDict: + """Serialize this response for JSON persistence. + + Captures exactly what is needed to continue the conversation: + model id, the input chain that was sent + (``response.prompt.messages``), the structured assistant output + (``response.messages``), and any explicit options. Pair with + :meth:`Response.from_dict` to rehydrate and + :meth:`Response.reply` to continue. + + Returns :class:`~llm.serialization.ResponseDict`. + """ + return _response_to_dict(self) + + @classmethod + def from_dict( + cls, + data: ResponseDict, + *, + model: Optional["Model"] = None, + ) -> "Response": + """Rehydrate a Response from a ``to_dict()`` payload. + + The returned Response is in the ``_done`` state with + ``response.text()`` and ``response.messages`` populated. + ``model`` overrides the stored model id (useful for continuing + on a different model). + """ + return cast( + "Response", _response_from_dict(data, cls, model=model, async_=False) + ) + + def on_done(self, callback): + "Register a callback to be called when the response is complete." + if not self._done: + self.done_callbacks.append(callback) + else: + callback(self) + + def _on_done(self): + for callback in self.done_callbacks: + callback(self) + + def __str__(self) -> str: + return self.text() + + def _force(self): + if not self._done: + list(self) + + def text(self) -> str: + "Return the full text of the response, executing the prompt if needed." + self._force() + return "".join(self._chunks) + + def text_or_raise(self) -> str: + return self.text() + + def execute_tool_calls( + self, + *, + before_call: Optional[BeforeCallSync] = None, + after_call: Optional[AfterCallSync] = None, + tool_calls_list: Optional[List[ToolCall]] = None, + ) -> List[ToolResult]: + """Execute tool calls using this response's tools. + + By default executes ``self.tool_calls()``; pass + ``tool_calls_list=`` to execute an explicit list instead (used + when resuming a chain whose history ends in unresolved calls). + """ + tool_results = [] + tools_by_name = {tool.name: tool for tool in self.prompt.tools} + if tool_calls_list is None: + tool_calls_list = self.tool_calls() + + # Run prepare() on all Toolbox instances that need it + instances_to_prepare: list[Toolbox] = [] + for tool_to_prep in tools_by_name.values(): + inst = _get_instance(tool_to_prep.implementation) + if isinstance(inst, Toolbox) and not getattr(inst, "_prepared", False): + instances_to_prepare.append(inst) + + for inst in instances_to_prepare: + inst.prepare() + inst._prepared = True + + for tool_call in tool_calls_list: + tool: Optional[Tool] = tools_by_name.get(tool_call.name) + # Tool could be None if the tool was not found in the prompt tools, + # but we still call the before_call method: + if before_call: + try: + cb_result = before_call(tool, tool_call) + if inspect.isawaitable(cb_result): + raise TypeError( + "Asynchronous 'before_call' callback provided to a synchronous tool execution context. " + "Please use an async chain/response or a synchronous callback." + ) + except CancelToolCall as ex: + tool_results.append( + ToolResult( + name=tool_call.name, + output="Cancelled: " + str(ex), + tool_call_id=tool_call.tool_call_id, + exception=ex, + ) + ) + continue + + if tool is None: + msg = 'tool "{}" does not exist'.format(tool_call.name) + tool_results.append( + ToolResult( + name=tool_call.name, + output="Error: " + msg, + tool_call_id=tool_call.tool_call_id, + exception=KeyError(msg), + ) + ) + continue + + if not tool.implementation: + raise ValueError( + "No implementation available for tool: {}".format(tool_call.name) + ) + + attachments = [] + exception = None + + try: + implementation_arguments = _implementation_arguments(tool, tool_call) + if inspect.iscoroutinefunction(tool.implementation): + result = asyncio.run( + tool.implementation(**implementation_arguments) + ) + else: + result = tool.implementation(**implementation_arguments) + + if isinstance(result, ToolOutput): + attachments = result.attachments + result = result.output + + if not isinstance(result, str): + result = json.dumps(result, default=repr) + except PauseChain as ex: + # Pause: propagate instead of converting to an error + # result. Sequential execution stops here - later calls + # never started, so they can safely run on resume. + ex.tool_call = tool_call + ex.tool_results = list(tool_results) + raise + except Exception as ex: + result = f"Error: {ex}" + exception = ex + + tool_result_obj = ToolResult( + name=tool_call.name, + output=result, + attachments=attachments, + tool_call_id=tool_call.tool_call_id, + instance=_get_instance(tool.implementation), + exception=exception, + ) + + if after_call: + cb_result = after_call(tool, tool_call, tool_result_obj) + if inspect.isawaitable(cb_result): + raise TypeError( + "Asynchronous 'after_call' callback provided to a synchronous tool execution context. " + "Please use an async chain/response or a synchronous callback." + ) + tool_results.append(tool_result_obj) + return tool_results + + def tool_calls(self) -> List[ToolCall]: + "Return the list of tool calls made during this response." + self._force() + return self._tool_calls + + def tool_calls_or_raise(self) -> List[ToolCall]: + return self.tool_calls() + + def json(self) -> Optional[Dict[str, Any]]: + "Return the raw JSON response from the model, if available." + self._force() + return self.response_json + + def duration_ms(self) -> int: + self._force() + return int(((self._end or 0) - (self._start or 0)) * 1000) + + def datetime_utc(self) -> str: + self._force() + return self._start_utcnow.isoformat() if self._start_utcnow else "" + + def usage(self) -> Usage: + "Return token usage information for this response." + self._force() + return Usage( + input=self.input_tokens, + output=self.output_tokens, + details=self.token_details, + ) + + def _iter_events(self): + """Drive self.model.execute() once and yield each raw chunk it + produces. Callers normalize chunks through _process_chunk. + """ + if isinstance(self.model, Model): + generator = self.model.execute( + self.prompt, + stream=self.stream, + response=self, + conversation=self.conversation, + ) + elif isinstance(self.model, KeyModel): + generator = self.model.execute( + self.prompt, + stream=self.stream, + response=self, + conversation=self.conversation, + key=self.model.get_key(self._key), + ) + else: + raise Exception("self.model must be a Model or KeyModel") + + for chunk in generator: + assert chunk is not None + yield chunk + + def __iter__(self) -> Iterator[str]: + self._start = time.monotonic() + self._start_utcnow = datetime.datetime.now(datetime.timezone.utc) + if self._done: + yield from self._chunks + return + + for chunk in self._iter_events(): + text = self._process_chunk(chunk) + if text is not None: + yield text + + if self.conversation: + self.conversation.responses.append(self) + self._end = time.monotonic() + self._done = True + self._on_done() + + def stream_events(self): + """Yield StreamEvent objects as the model produces them. + + Whichever of __iter__ and stream_events runs first during live + streaming consumes the underlying generator. After completion, + both work — each replays from its own buffer. + """ + if self._done: + yield from self._stream_events + return + + self._start = time.monotonic() + self._start_utcnow = datetime.datetime.now(datetime.timezone.utc) + for chunk in self._iter_events(): + # _process_chunk appends to self._stream_events; use it as + # the canonical source for what to yield so the replay path + # matches the live path byte-for-byte. + self._process_chunk(chunk) + yield self._stream_events[-1] + + if self.conversation: + self.conversation.responses.append(self) + self._end = time.monotonic() + self._done = True + self._on_done() + + def messages(self) -> List[Any]: + """List of Message objects produced by this response. + + Almost always a single assistant Message; multiple messages are + possible for providers that emit multi-message responses during + server-side tool execution. + + Forces execution if the response has not yet been drained, so + ``response.messages()`` is safe to call without a prior + ``response.text()`` / iteration. + + Responses rehydrated via ``Response.from_dict`` short-circuit + and return the stored messages directly. + """ + self._force() + return self._messages_now() + + def __repr__(self): + text = "... not yet done ..." + if self._done: + text = "".join(self._chunks) + return "".format(self.prompt.prompt, text) + + +class AsyncResponse(_BaseResponse): + "Async response from a model." + + model: "AsyncModel" + conversation: Optional["AsyncConversation"] = None + + async def reply( + self, + prompt: Optional[str] = None, + *, + messages: Optional[List[Any]] = None, + tool_results: Optional[List[ToolResult]] = None, + options: Optional[dict] = None, + **kwargs, + ) -> "AsyncResponse": + """Async counterpart of Response.reply(). Requires this response + to have been awaited (so self.messages is available). + + Awaitable so the auto-execute path can ``await + self.execute_tool_calls()``. See ``Response.reply`` for the + ``tool_results=`` semantics. + """ + from .parts import Message, TextPart, ToolResultPart + + if not self._done: + raise ValueError( + "Response not yet awaited — call `await response` before reply()" + ) + if tool_results is None and self._tool_calls: + tool_results = await self.execute_tool_calls() + if "tools" not in kwargs and self.prompt.tools: + kwargs["tools"] = self.prompt.tools + chain: List[Any] = list(self.prompt.messages) + list(self._messages_now()) + if tool_results: + chain.append( + Message( + role="tool", + parts=[ + ToolResultPart( + name=tr.name, + output=tr.output, + tool_call_id=tr.tool_call_id, + ) + for tr in tool_results + ], + ) + ) + if prompt: + chain.append(Message(role="user", parts=[TextPart(text=prompt)])) + if messages: + chain.extend(messages) + return self.model.prompt(messages=chain, options=options, **kwargs) + + def to_dict(self) -> ResponseDict: + """Async counterpart of Response.to_dict(). Requires awaiting.""" + if not self._done: + raise ValueError( + "Response not yet awaited — call `await response` before to_dict()" + ) + return _response_to_dict(self) + + @classmethod + def from_dict( + cls, + data: ResponseDict, + *, + model: Optional["AsyncModel"] = None, + ) -> "AsyncResponse": + """Async counterpart of Response.from_dict().""" + return cast( + "AsyncResponse", _response_from_dict(data, cls, model=model, async_=True) + ) + + @classmethod + def from_row(cls, db, row, _async=False): + return super().from_row(db, row, _async=True) + + async def on_done(self, callback): + "Register a callback to be called when the response is complete." + if not self._done: + self.done_callbacks.append(callback) + else: + if callable(callback): + # Ensure we handle both sync and async callbacks correctly + processed_callback = callback(self) + if inspect.isawaitable(processed_callback): + await processed_callback + elif inspect.isawaitable(callback): + await callback + + async def _on_done(self): + for callback_func in self.done_callbacks: + if callable(callback_func): + processed_callback = callback_func(self) + if inspect.isawaitable(processed_callback): + await processed_callback + elif inspect.isawaitable(callback_func): + await callback_func + + async def execute_tool_calls( + self, + *, + before_call: Optional[BeforeCallAsync] = None, + after_call: Optional[AfterCallAsync] = None, + tool_calls_list: Optional[List[ToolCall]] = None, + ) -> List[ToolResult]: + """Execute tool calls using this response's tools. + + By default executes ``await self.tool_calls()``; pass + ``tool_calls_list=`` to execute an explicit list instead (used + when resuming a chain whose history ends in unresolved calls). + """ + if tool_calls_list is None: + tool_calls_list = await self.tool_calls() + tools_by_name = {tool.name: tool for tool in self.prompt.tools} + + # Run async prepare_async() on all Toolbox instances that need it + instances_to_prepare: list[Toolbox] = [] + for tool_to_prep in tools_by_name.values(): + inst = _get_instance(tool_to_prep.implementation) + if isinstance(inst, Toolbox) and not getattr( + inst, "_async_prepared", False + ): + instances_to_prepare.append(inst) + + for inst in instances_to_prepare: + await inst.prepare_async() + inst._async_prepared = True + + indexed_results: List[tuple[int, ToolResult]] = [] + async_tasks: List[asyncio.Task] = [] + async_task_indexes: List[int] = [] + # Defined failure semantics: a pause or error in one call must not + # orphan concurrently-running siblings. Pauses and hook failures + # are collected here and raised only after every task that was + # started has finished. + paused: List[tuple[int, PauseChain]] = [] + failures: List[tuple[int, BaseException]] = [] + + for idx, tc in enumerate(tool_calls_list): + tool: Optional[Tool] = tools_by_name.get(tc.name) + exception: Optional[Exception] = None + + if tool is None or not tool.implementation: + # Mirror the sync executor: append an error ToolResult so + # the provider still receives a result for every tool + # call. before_call fires even though the tool is + # unavailable. + if before_call: + try: + cb = before_call(tool, tc) + if inspect.isawaitable(cb): + await cb + except CancelToolCall as ex: + indexed_results.append( + ( + idx, + ToolResult( + name=tc.name, + output="Cancelled: " + str(ex), + tool_call_id=tc.tool_call_id, + exception=ex, + ), + ) + ) + continue + except Exception as ex: + failures.append((idx, ex)) + break + reason = "does not exist" if tool is None else "has no implementation" + msg = 'tool "{}" {}'.format(tc.name, reason) + indexed_results.append( + ( + idx, + ToolResult( + name=tc.name, + output="Error: " + msg, + tool_call_id=tc.tool_call_id, + exception=KeyError(msg), + ), + ) + ) + continue + + if inspect.iscoroutinefunction(tool.implementation): + + async def run_async(tc=tc, tool=tool, idx=idx): + # before_call inside the task + if before_call: + try: + cb = before_call(tool, tc) + if inspect.isawaitable(cb): + await cb + except CancelToolCall as ex: + return idx, ToolResult( + name=tc.name, + output="Cancelled: " + str(ex), + tool_call_id=tc.tool_call_id, + exception=ex, + ) + + exception = None + attachments = [] + + try: + result = await tool.implementation( + **_implementation_arguments(tool, tc) + ) + if isinstance(result, ToolOutput): + attachments.extend(result.attachments) + result = result.output + output = ( + result + if isinstance(result, str) + else json.dumps(result, default=repr) + ) + except PauseChain as ex: + # Propagates out of the task; collected after + # the gather so siblings finish first. + ex.tool_call = tc + raise + except Exception as ex: + output = f"Error: {ex}" + exception = ex + + tr = ToolResult( + name=tc.name, + output=output, + attachments=attachments, + tool_call_id=tc.tool_call_id, + instance=_get_instance(tool.implementation), + exception=exception, + ) + + # after_call inside the task + if tool is not None and after_call: + cb2 = after_call(tool, tc, tr) + if inspect.isawaitable(cb2): + await cb2 + + return idx, tr + + async_tasks.append(asyncio.create_task(run_async())) + async_task_indexes.append(idx) + + else: + # Sync implementation: do hooks and call inline + if before_call: + try: + cb = before_call(tool, tc) + if inspect.isawaitable(cb): + await cb + except CancelToolCall as ex: + indexed_results.append( + ( + idx, + ToolResult( + name=tc.name, + output="Cancelled: " + str(ex), + tool_call_id=tc.tool_call_id, + exception=ex, + ), + ) + ) + continue + except Exception as ex: + failures.append((idx, ex)) + break + + exception = None + attachments = [] + + try: + res = tool.implementation(**_implementation_arguments(tool, tc)) + if inspect.isawaitable(res): + res = await res + if isinstance(res, ToolOutput): + attachments.extend(res.attachments) + res = res.output + output = ( + res if isinstance(res, str) else json.dumps(res, default=repr) + ) + except PauseChain as ex: + # Inline execution stops here; later calls never + # start. Tasks already started are still awaited + # below before the pause propagates. + ex.tool_call = tc + paused.append((idx, ex)) + break + except Exception as ex: + output = f"Error: {ex}" + exception = ex + + tr = ToolResult( + name=tc.name, + output=output, + attachments=attachments, + tool_call_id=tc.tool_call_id, + instance=_get_instance(tool.implementation), + exception=exception, + ) + + try: + if after_call: + cb2 = after_call(tool, tc, tr) + if inspect.isawaitable(cb2): + await cb2 + except Exception as ex: + failures.append((idx, ex)) + break + + indexed_results.append((idx, tr)) + + # Await every task that was started; return_exceptions so a pause + # or hook failure in one task cannot orphan its siblings mid-flight. + if async_tasks: + outcomes = await asyncio.gather(*async_tasks, return_exceptions=True) + for task_idx, outcome in zip(async_task_indexes, outcomes): + if isinstance(outcome, PauseChain): + paused.append((task_idx, outcome)) + elif isinstance(outcome, BaseException): + failures.append((task_idx, outcome)) + else: + indexed_results.append(outcome) + + # Reorder by original index + indexed_results.sort(key=lambda x: x[0]) + results = [tr for _, tr in indexed_results] + + # Hook failures are bugs: raise the first by call order. + if failures: + failures.sort(key=lambda item: item[0]) + raise failures[0][1] + + # Pauses propagate with the completed sibling results attached. + if paused: + paused.sort(key=lambda item: item[0]) + pause = paused[0][1] + pause.tool_results = results + raise pause + + return results + + def __aiter__(self): + self._start = time.monotonic() + self._start_utcnow = datetime.datetime.now(datetime.timezone.utc) + if self._done: + self._iter_chunks = list(self._chunks) # Make a copy for iteration + return self + + def _ensure_async_generator(self): + if not hasattr(self, "_generator"): + if isinstance(self.model, AsyncModel): + self._generator = self.model.execute( + self.prompt, + stream=self.stream, + response=self, + conversation=self.conversation, + ) + elif isinstance(self.model, AsyncKeyModel): + self._generator = self.model.execute( + self.prompt, + stream=self.stream, + response=self, + conversation=self.conversation, + key=self.model.get_key(self._key), + ) + else: + raise ValueError("self.model must be an AsyncModel or AsyncKeyModel") + + async def _async_finalize(self): + if self.conversation: + self.conversation.responses.append(self) + self._end = time.monotonic() + self._done = True + if hasattr(self, "_generator"): + del self._generator + await self._on_done() + + async def __anext__(self) -> str: + if self._done: + if hasattr(self, "_iter_chunks") and self._iter_chunks: + return self._iter_chunks.pop(0) + raise StopAsyncIteration + + self._ensure_async_generator() + # Skip non-text events — iteration yields only text. Loop until + # we find a text chunk or the generator is exhausted. + while True: + try: + chunk = await self._generator.__anext__() + except StopAsyncIteration: + await self._async_finalize() + raise + assert chunk is not None + text = self._process_chunk(chunk) + if text is not None: + return text + + async def astream_events(self): + """Yield StreamEvent objects as the model produces them (async).""" + if self._done: + for event in self._stream_events: + yield event + return + + self._start = time.monotonic() + self._start_utcnow = datetime.datetime.now(datetime.timezone.utc) + self._ensure_async_generator() + try: + while True: + try: + chunk = await self._generator.__anext__() + except StopAsyncIteration: + await self._async_finalize() + return + assert chunk is not None + self._process_chunk(chunk) + yield self._stream_events[-1] + finally: + pass + + async def messages(self) -> List[Any]: + """List of Message objects produced by this response. + + Awaits ``self._force()`` so ``await response.messages()`` is + safe to call without first awaiting ``response.text()`` or + iterating the stream. Responses rehydrated via + ``AsyncResponse.from_dict`` short-circuit and return the + stored messages. + """ + await self._force() + return self._messages_now() + + async def _force(self): + if not self._done: + temp_chunks = [] + async for chunk in self: + temp_chunks.append(chunk) + # This should populate self._chunks + return self + + def text_or_raise(self) -> str: + if not self._done: + raise ValueError("Response not yet awaited") + return "".join(self._chunks) + + async def text(self) -> str: + "Return the full text of the response, executing the prompt if needed." + await self._force() + return "".join(self._chunks) + + async def tool_calls(self) -> List[ToolCall]: + "Return the list of tool calls made during this response." + await self._force() + return self._tool_calls + + def tool_calls_or_raise(self) -> List[ToolCall]: + if not self._done: + raise ValueError("Response not yet awaited") + return self._tool_calls + + async def json(self) -> Optional[Dict[str, Any]]: + "Return the raw JSON response from the model, if available." + await self._force() + return self.response_json + + async def duration_ms(self) -> int: + await self._force() + return int(((self._end or 0) - (self._start or 0)) * 1000) + + async def datetime_utc(self) -> str: + await self._force() + return self._start_utcnow.isoformat() if self._start_utcnow else "" + + async def usage(self) -> Usage: + "Return token usage information for this response." + await self._force() + return Usage( + input=self.input_tokens, + output=self.output_tokens, + details=self.token_details, + ) + + def __await__(self): + return self._force().__await__() + + async def to_sync_response(self) -> Response: + await self._force() + # This conversion might be tricky if the model is AsyncModel, + # as Response expects a sync Model. For simplicity, we'll assume + # the primary use case is data transfer after completion. + # The model type on the new Response might need careful handling + # if it's intended for further execution. + # For now, let's assume self.model can be cast or is compatible. + sync_model = self.model + if not isinstance(self.model, (Model, KeyModel)): + # This is a placeholder. A proper conversion or shared base might be needed + # if the sync_response needs to be fully functional with its model. + # For now, we pass the async model, which might limit what sync_response can do. + pass + + response = Response( + self.prompt, + sync_model, # This might need adjustment based on how Model/AsyncModel relate + self.stream, + # conversation type needs to be compatible too. + conversation=( + self.conversation.to_sync_conversation() if self.conversation else None + ), + ) + response.id = self.id + response._chunks = list(self._chunks) # Copy chunks + response._done = self._done + response._end = self._end + response._start = self._start + response._start_utcnow = self._start_utcnow + response.input_tokens = self.input_tokens + response.output_tokens = self.output_tokens + response.token_details = self.token_details + response._prompt_json = self._prompt_json + response.response_json = self.response_json + response._tool_calls = list(self._tool_calls) + response.attachments = list(self.attachments) + response.resolved_model = self.resolved_model + return response + + @classmethod + def fake( + cls, + model: "AsyncModel", + prompt: str, + *attachments: List[Attachment], + system: str, + response: str, + ): + "Utility method to help with writing tests" + response_obj = cls( + model=model, + prompt=Prompt( + prompt, + model=model, + attachments=attachments, + system=system, + ), + stream=False, + ) + response_obj._done = True + response_obj._chunks = [response] + return response_obj + + def __repr__(self): + text = "... not yet awaited ..." + if self._done: + text = "".join(self._chunks) + return "".format(self.prompt.prompt, text) + + +def _append_tool_results_to_chain(chain, tool_results, attachments) -> List[Any]: + """Append a tool-role message carrying ToolResults to a message + chain, plus a trailing user-role message for any attachments the + tools returned (mimics the legacy attachments=[] kwarg behavior).""" + from .parts import ( + AttachmentPart, + Message, + ToolResultPart, + ) + + if tool_results: + chain.append( + Message( + role="tool", + parts=[ + ToolResultPart( + name=tr.name, + output=tr.output, + tool_call_id=tr.tool_call_id, + ) + for tr in tool_results + ], + ) + ) + if attachments: + chain.append( + Message( + role="user", + parts=[AttachmentPart(attachment=a) for a in attachments], + ) + ) + return chain + + +def _chain_for_tool_results(prior_response, tool_results, attachments) -> List[Any]: + """Build the message chain for a tool-result turn in a chain loop. + + Takes the prior response's full input chain + its structured + output, then appends a tool-role message carrying the new + ToolResult outputs. + + This is what gives ``response.prompt.messages`` on the tool- + result turn the complete history for the next provider call — + including any reasoning signatures or thoughtSignatures from the + prior turn. + """ + chain: List[Any] = list(prior_response.prompt.messages) + list( + prior_response._messages_now() + ) + return _append_tool_results_to_chain(chain, tool_results, attachments) + + +def _trailing_pending_tool_calls(messages) -> List[ToolCall]: + """Find unresolved tool calls at the end of a message history. + + Returns ToolCall objects from the last assistant message containing + locally-executable tool_call parts, minus any that already have a + matching tool_result in subsequent tool-role messages. Returns [] + when the history has moved on past those calls (a user/assistant/ + system message follows them) - resuming only makes sense when the + calls are the latest thing that happened. + + Matching uses tool_call_id when present; id-less calls (histories + persisted before ids were guaranteed) match results by name, one + result consumed per call. + """ + from .parts import ToolCallPart, ToolResultPart + + last_index = None + call_parts: List[Any] = [] + for i, msg in enumerate(messages or []): + parts = getattr(msg, "parts", None) or [] + calls = [ + p for p in parts if isinstance(p, ToolCallPart) and not p.server_executed + ] + if getattr(msg, "role", None) == "assistant" and calls: + last_index = i + call_parts = calls + if last_index is None: + return [] + + results: List[Any] = [] + for msg in messages[last_index + 1 :]: + role = getattr(msg, "role", None) + if role == "tool": + results.extend( + p + for p in (getattr(msg, "parts", None) or []) + if isinstance(p, ToolResultPart) + ) + else: + # Conversation moved on past these calls + return [] + + matched_ids = {r.tool_call_id for r in results if r.tool_call_id} + unmatched_names = [r.name for r in results if not r.tool_call_id] + pending = [] + for part in call_parts: + if part.tool_call_id: + if part.tool_call_id in matched_ids: + continue + elif part.name in unmatched_names: + unmatched_names.remove(part.name) + continue + pending.append( + ToolCall( + name=part.name, + arguments=part.arguments or {}, + tool_call_id=part.tool_call_id, + ) + ) + return pending + + +class _BaseChainResponse: + prompt: "Prompt" + stream: bool + conversation: Optional["_BaseConversation"] = None + _key: Optional[str] = None + + def __init__( + self, + prompt: Prompt, + model: "_BaseModel", + stream: bool, + conversation: _BaseConversation, + key: Optional[str] = None, + chain_limit: Optional[int] = 10, + before_call: Optional[Union[BeforeCallSync, BeforeCallAsync]] = None, + after_call: Optional[Union[AfterCallSync, AfterCallAsync]] = None, + ): + self.prompt = prompt + self.model = model + self.stream = stream + self._key = key + self._responses: List[Any] = [] + self.conversation = conversation + self.chain_limit = chain_limit + self.before_call = before_call + self.after_call = after_call + + def log_to_db(self, db): + for response in self._responses: + if isinstance(response, AsyncResponse): + sync_response = asyncio.run(response.to_sync_response()) + elif isinstance(response, Response): + sync_response = response + else: + assert False, "Should have been a Response or AsyncResponse" + sync_response.log_to_db(db) + + def _pending_tool_calls(self) -> List[ToolCall]: + """Unresolved tool calls at the end of this chain's history. + + Non-empty when the supplied messages= end in an assistant + message whose tool calls have no results yet - e.g. a chain + that paused on PauseChain and is being resumed from persisted + history.""" + if not self.prompt.tools: + return [] + return _trailing_pending_tool_calls(self.prompt.messages) + + def _resume_prompt(self, tool_results: List[ToolResult]) -> Prompt: + """The first prompt for a resumed chain: the original history + plus a tool-role message carrying the freshly-executed results - + the same shape as the chain loop's own tool-result turns.""" + prompt = self.prompt + attachments = [] + for tool_result in tool_results: + attachments.extend(tool_result.attachments) + next_chain = _append_tool_results_to_chain( + list(prompt.messages), tool_results, attachments + ) + return Prompt( + "", + self.model, + tools=prompt.tools, + tool_results=tool_results, + messages=next_chain, + system=prompt._system, + system_fragments=prompt.system_fragments, + options=prompt.options, + attachments=attachments, + hide_reasoning=prompt.hide_reasoning, + ) + + +class ChainResponse(_BaseChainResponse): + _responses: List["Response"] + before_call: Optional[BeforeCallSync] = None + after_call: Optional[AfterCallSync] = None + + def responses(self) -> Iterator[Response]: + prompt = self.prompt + count = 0 + initial_response = Response( + prompt, + self.model, + self.stream, + key=self._key, + conversation=self.conversation, + ) + # Resume: a history ending in unresolved tool calls means a + # previous run stopped (paused or crashed) before executing + # them. Execute those calls first - through the normal + # before_call/after_call machinery - then start the loop on + # the tool-result turn. This could raise llm.PauseChain. + pending_tool_calls = self._pending_tool_calls() + if pending_tool_calls: + tool_results = initial_response.execute_tool_calls( + before_call=self.before_call, + after_call=self.after_call, + tool_calls_list=pending_tool_calls, + ) + initial_response = Response( + self._resume_prompt(tool_results), + self.model, + self.stream, + key=self._key, + conversation=self.conversation, + ) + current_response: Optional[Response] = initial_response + while current_response: + count += 1 + yield current_response + self._responses.append(current_response) + if self.chain_limit and count >= self.chain_limit: + raise ValueError(f"Chain limit of {self.chain_limit} exceeded.") + + # This could raise llm.CancelToolCall: + tool_results = current_response.execute_tool_calls( + before_call=self.before_call, after_call=self.after_call + ) + attachments = [] + for tool_result in tool_results: + attachments.extend(tool_result.attachments) + if tool_results: + # Pre-bake the full chain for the tool-result turn so + # response.prompt.messages is what gets sent — carries + # thoughtSignatures, thinking signatures, and everything + # else the model needs for the next call. + next_chain = _chain_for_tool_results( + current_response, tool_results, attachments + ) + current_response = Response( + Prompt( + "", # Next prompt text is empty; tool_results drive it + self.model, + tools=current_response.prompt.tools, + tool_results=tool_results, + messages=next_chain, + # Carry system + system_fragments forward so + # stateless-per-turn adapters (OpenAI and + # friends that read prompt.system directly) + # keep seeing the system prompt on every call + # of the chain loop. + system=self.prompt._system, + system_fragments=self.prompt.system_fragments, + options=self.prompt.options, + attachments=attachments, + hide_reasoning=current_response.prompt.hide_reasoning, + ), + self.model, + stream=self.stream, + key=self._key, + conversation=self.conversation, + ) + else: + current_response = None + break + + def __iter__(self) -> Iterator[str]: + for response_item in self.responses(): + yield from response_item + + def stream_events(self): + "Yield StreamEvents from every response in the chain." + for response_item in self.responses(): + yield from response_item.stream_events() + + def text(self) -> str: + return "".join(self) + + +class AsyncChainResponse(_BaseChainResponse): + _responses: List["AsyncResponse"] + before_call: Optional[BeforeCallAsync] = None + after_call: Optional[AfterCallAsync] = None + + async def responses(self) -> AsyncIterator[AsyncResponse]: + prompt = self.prompt + count = 0 + initial_response = AsyncResponse( + prompt, + self.model, + self.stream, + key=self._key, + conversation=self.conversation, + ) + # Resume: see ChainResponse.responses() - execute trailing + # unresolved tool calls before the first provider call. This + # could raise llm.PauseChain. + pending_tool_calls = self._pending_tool_calls() + if pending_tool_calls: + tool_results = await initial_response.execute_tool_calls( + before_call=self.before_call, + after_call=self.after_call, + tool_calls_list=pending_tool_calls, + ) + initial_response = AsyncResponse( + self._resume_prompt(tool_results), + self.model, + self.stream, + key=self._key, + conversation=self.conversation, + ) + current_response: Optional[AsyncResponse] = initial_response + while current_response: + count += 1 + yield current_response + self._responses.append(current_response) + + if self.chain_limit and count >= self.chain_limit: + raise ValueError(f"Chain limit of {self.chain_limit} exceeded.") + + # This could raise llm.CancelToolCall: + tool_results = await current_response.execute_tool_calls( + before_call=self.before_call, after_call=self.after_call + ) + if tool_results: + attachments = [] + for tool_result in tool_results: + attachments.extend(tool_result.attachments) + # Pre-bake chain so prompt.messages carries full history + # + any thinking/tool-call signatures from prior turn. + next_chain = _chain_for_tool_results( + current_response, tool_results, attachments + ) + prompt = Prompt( + "", + self.model, + tools=current_response.prompt.tools, + tool_results=tool_results, + messages=next_chain, + # Carry system + system_fragments forward — same + # reasoning as the sync path. + system=self.prompt._system, + system_fragments=self.prompt.system_fragments, + options=self.prompt.options, + attachments=attachments, + hide_reasoning=current_response.prompt.hide_reasoning, + ) + current_response = AsyncResponse( + prompt, + self.model, + stream=self.stream, + key=self._key, + conversation=self.conversation, + ) + else: + current_response = None + break + + async def __aiter__(self) -> AsyncIterator[str]: + async for response_item in self.responses(): + async for chunk in response_item: + yield chunk + + async def astream_events(self): + "Yield StreamEvents from every response in the chain." + async for response_item in self.responses(): + async for event in response_item.astream_events(): + yield event + + async def text(self) -> str: + all_chunks = [] + async for chunk in self: + all_chunks.append(chunk) + return "".join(all_chunks) + + +class Options(BaseModel): + model_config = ConfigDict(extra="forbid") + + +_Options = Options + + +class _get_key_mixin: + needs_key: Optional[str] = None + key: Optional[str] = None + key_env_var: Optional[str] = None + + def get_key(self, explicit_key: Optional[str] = None) -> Optional[str]: + from llm import get_key + + if self.needs_key is None: + # This model doesn't use an API key + return None + + if self.key is not None: + # Someone already set model.key='...' + return self.key + + # Attempt to load a key using llm.get_key() + key_value = get_key( + explicit_key=explicit_key, + key_alias=self.needs_key, + env_var=self.key_env_var, + ) + if key_value: + return key_value + + # Show a useful error message + message = "No key found - add one using 'llm keys set {}'".format( + self.needs_key + ) + if self.key_env_var: + message += " or set the {} environment variable".format(self.key_env_var) + raise NeedsKeyException(message) + + +class _BaseModel(ABC, _get_key_mixin): + model_id: str + can_stream: bool = False + attachment_types: Set = set() + + supports_schema = False + supports_tools = False + + class Options(_Options): + pass + + def _validate_attachments( + self, attachments: Optional[List[Attachment]] = None + ) -> None: + if attachments and not self.attachment_types: + raise ValueError("This model does not support attachments") + for attachment in attachments or []: + attachment_type = attachment.resolve_type() + if attachment_type not in self.attachment_types: + raise ValueError( + f"This model does not support attachments of type '{attachment_type}', " + f"only {', '.join(self.attachment_types)}" + ) + + def __str__(self) -> str: + return "{}{}: {}".format( + self.__class__.__name__, + " (async)" if isinstance(self, (AsyncModel, AsyncKeyModel)) else "", + self.model_id, + ) + + def __repr__(self) -> str: + return f"<{str(self)}>" + + +class _Model(_BaseModel): + def conversation( + self, + tools: Optional[List[ToolDef]] = None, + before_call: Optional[BeforeCallSync] = None, + after_call: Optional[AfterCallSync] = None, + chain_limit: Optional[int] = None, + ) -> Conversation: + return Conversation( + model=self, + tools=tools, + before_call=before_call, + after_call=after_call, + chain_limit=chain_limit, + ) + + def prompt( + self, + prompt: Optional[str] = None, + *, + fragments: Optional[List[Union[str, Fragment]]] = None, + attachments: Optional[List[Attachment]] = None, + system: Optional[str] = None, + system_fragments: Optional[List[Union[str, Fragment]]] = None, + messages: Optional[List[Any]] = None, + stream: bool = True, + schema: Optional[Union[dict, type[BaseModel]]] = None, + tools: Optional[List[ToolDef]] = None, + tool_results: Optional[List[ToolResult]] = None, + options: Optional[dict] = None, + hide_reasoning: bool = False, + **kwargs, + ) -> Response: + key_value = kwargs.pop("key", None) + merged = _merge_options(options, kwargs) + self._validate_attachments(attachments) + return Response( + Prompt( + prompt, + fragments=fragments, + attachments=attachments, + system=system, + schema=schema, + tools=tools, + tool_results=tool_results, + system_fragments=system_fragments, + messages=messages, + model=self, + options=self.Options(**merged), + hide_reasoning=hide_reasoning, + ), + self, + stream, + key=key_value, + ) + + def chain( + self, + prompt: Optional[str] = None, + *, + fragments: Optional[List[str]] = None, + attachments: Optional[List[Attachment]] = None, + system: Optional[str] = None, + system_fragments: Optional[List[str]] = None, + messages: Optional[List[Any]] = None, + stream: bool = True, + schema: Optional[Union[dict, type[BaseModel]]] = None, + tools: Optional[List[ToolDef]] = None, + tool_results: Optional[List[ToolResult]] = None, + before_call: Optional[BeforeCallSync] = None, + after_call: Optional[AfterCallSync] = None, + key: Optional[str] = None, + options: Optional[dict] = None, + hide_reasoning: bool = False, + ) -> ChainResponse: + return self.conversation().chain( + prompt=prompt, + fragments=fragments, + attachments=attachments, + system=system, + system_fragments=system_fragments, + messages=messages, + stream=stream, + schema=schema, + tools=tools, + tool_results=tool_results, + before_call=before_call, + after_call=after_call, + key=key, + options=options, + hide_reasoning=hide_reasoning, + ) + + +class Model(_Model): + @abstractmethod + def execute( + self, + prompt: Prompt, + stream: bool, + response: Response, + conversation: Optional[Conversation], + ) -> Iterator[Union[str, "StreamEvent"]]: + pass + + +class KeyModel(_Model): + @abstractmethod + def execute( + self, + prompt: Prompt, + stream: bool, + response: Response, + conversation: Optional[Conversation], + key: Optional[str], + ) -> Iterator[Union[str, "StreamEvent"]]: + pass + + +class _AsyncModel(_BaseModel): + def conversation( + self, + tools: Optional[List[ToolDef]] = None, + before_call: Optional[BeforeCallAsync] = None, + after_call: Optional[AfterCallAsync] = None, + chain_limit: Optional[int] = None, + ) -> AsyncConversation: + return AsyncConversation( + model=self, + tools=tools, + before_call=before_call, + after_call=after_call, + chain_limit=chain_limit, + ) + + def prompt( + self, + prompt: Optional[str] = None, + *, + fragments: Optional[List[Union[str, Fragment]]] = None, + attachments: Optional[List[Attachment]] = None, + system: Optional[str] = None, + schema: Optional[Union[dict, type[BaseModel]]] = None, + tools: Optional[List[ToolDef]] = None, + tool_results: Optional[List[ToolResult]] = None, + system_fragments: Optional[List[Union[str, Fragment]]] = None, + messages: Optional[List[Any]] = None, + stream: bool = True, + options: Optional[dict] = None, + hide_reasoning: bool = False, + **kwargs, + ) -> AsyncResponse: + key_value = kwargs.pop("key", None) + merged = _merge_options(options, kwargs) + self._validate_attachments(attachments) + return AsyncResponse( + Prompt( + prompt, + fragments=fragments, + attachments=attachments, + system=system, + schema=schema, + tools=tools, + tool_results=tool_results, + system_fragments=system_fragments, + messages=messages, + model=self, + options=self.Options(**merged), + hide_reasoning=hide_reasoning, + ), + self, + stream, + key=key_value, + ) + + def chain( + self, + prompt: Optional[str] = None, + *, + fragments: Optional[List[str]] = None, + attachments: Optional[List[Attachment]] = None, + system: Optional[str] = None, + system_fragments: Optional[List[str]] = None, + messages: Optional[List[Any]] = None, + stream: bool = True, + schema: Optional[Union[dict, type[BaseModel]]] = None, + tools: Optional[List[ToolDef]] = None, + tool_results: Optional[List[ToolResult]] = None, + before_call: Optional[BeforeCallAsync] = None, + after_call: Optional[AfterCallAsync] = None, + key: Optional[str] = None, + options: Optional[dict] = None, + hide_reasoning: bool = False, + ) -> AsyncChainResponse: + return self.conversation().chain( + prompt=prompt, + fragments=fragments, + attachments=attachments, + system=system, + system_fragments=system_fragments, + messages=messages, + stream=stream, + schema=schema, + tools=tools, + tool_results=tool_results, + before_call=before_call, + after_call=after_call, + key=key, + options=options, + hide_reasoning=hide_reasoning, + ) + + +class AsyncModel(_AsyncModel): + @abstractmethod + async def execute( + self, + prompt: Prompt, + stream: bool, + response: AsyncResponse, + conversation: Optional[AsyncConversation], + ) -> AsyncGenerator[Union[str, "StreamEvent"], None]: + if False: # Ensure it's a generator type + yield "" + pass + + +class AsyncKeyModel(_AsyncModel): + @abstractmethod + async def execute( + self, + prompt: Prompt, + stream: bool, + response: AsyncResponse, + conversation: Optional[AsyncConversation], + key: Optional[str], + ) -> AsyncGenerator[Union[str, "StreamEvent"], None]: + if False: # Ensure it's a generator type + yield "" + pass + + +class EmbeddingModel(ABC, _get_key_mixin): + model_id: str + key: Optional[str] = None + needs_key: Optional[str] = None + key_env_var: Optional[str] = None + supports_text: bool = True + supports_binary: bool = False + batch_size: Optional[int] = None + + def _check(self, item: Union[str, bytes]): + if not self.supports_binary and isinstance(item, bytes): + raise ValueError( + "This model does not support binary data, only text strings" + ) + if not self.supports_text and isinstance(item, str): + raise ValueError( + "This model does not support text strings, only binary data" + ) + + def embed(self, item: Union[str, bytes]) -> List[float]: + "Embed a single text string or binary blob, return a list of floats" + self._check(item) + return next(iter(self.embed_batch([item]))) + + def embed_multi( + self, items: Iterable[Union[str, bytes]], batch_size: Optional[int] = None + ) -> Iterator[List[float]]: + "Embed multiple items in batches according to the model batch_size" + iter_items = iter(items) + effective_batch_size = self.batch_size if batch_size is None else batch_size + if (not self.supports_binary) or (not self.supports_text): + + def checking_iter(inner_items): + for item_to_check in inner_items: + self._check(item_to_check) + yield item_to_check + + iter_items = checking_iter(items) + if effective_batch_size is None: + yield from self.embed_batch(iter_items) + return + while True: + batch_items = list(islice(iter_items, effective_batch_size)) + if not batch_items: + break + yield from self.embed_batch(batch_items) + + @abstractmethod + def embed_batch(self, items: Iterable[Union[str, bytes]]) -> Iterator[List[float]]: + """ + Embed a batch of strings or blobs, return a list of lists of floats + """ + pass + + def __str__(self) -> str: + return "{}: {}".format(self.__class__.__name__, self.model_id) + + def __repr__(self) -> str: + return f"<{str(self)}>" + + +@dataclass +class ModelWithAliases: + "A model with its optional async counterpart and aliases." + + model: Model + async_model: AsyncModel + aliases: Set[str] + + def matches(self, query: str) -> bool: + query_lower = query.lower() + all_strings: List[str] = [] + all_strings.extend(self.aliases) + if self.model: + all_strings.append(str(self.model)) + if self.async_model: + all_strings.append(str(self.async_model.model_id)) + return any(query_lower in alias.lower() for alias in all_strings) + + +@dataclass +class EmbeddingModelWithAliases: + model: EmbeddingModel + aliases: Set[str] + + def matches(self, query: str) -> bool: + query_lower = query.lower() + all_strings: List[str] = [] + all_strings.extend(self.aliases) + all_strings.append(str(self.model)) + return any(query_lower in alias.lower() for alias in all_strings) + + +def _conversation_name(text): + # Collapse whitespace, including newlines + text = re.sub(r"\s+", " ", text) + if len(text) <= CONVERSATION_NAME_LENGTH: + return text + return text[: CONVERSATION_NAME_LENGTH - 1] + "…" + + +def _ensure_dict_schema(schema): + """Convert a Pydantic model to a JSON schema dict if needed.""" + if schema and not isinstance(schema, dict) and issubclass(schema, BaseModel): + schema_dict = schema.model_json_schema() + _remove_titles_recursively(schema_dict) + return schema_dict + return schema + + +def _remove_titles_recursively(obj): + """Recursively remove all 'title' fields from a nested dictionary.""" + if isinstance(obj, dict): + # Remove title if present + obj.pop("title", None) + + # Recursively process all values + for value in obj.values(): + _remove_titles_recursively(value) + elif isinstance(obj, list): + # Process each item in lists + for item in obj: + _remove_titles_recursively(item) + + +def _get_instance(implementation): + if hasattr(implementation, "__self__"): + return implementation.__self__ + return None diff --git a/llm/parts.py b/llm/parts.py new file mode 100644 index 0000000..5f5fd16 --- /dev/null +++ b/llm/parts.py @@ -0,0 +1,352 @@ +"""Part, Message, and StreamEvent value types. + +Parts represent the structured content of model interactions: text, +reasoning, tool calls, tool results, and attachments. A Message wraps a +list of Parts with a role. StreamEvent wraps a streaming chunk with type +information so consumers can distinguish text from reasoning from tool +call fragments as they arrive. + +These types are pure values — identity (ids, parent links, storage keys) +is a storage concern that lives elsewhere. Two Messages with identical +content are equal. +""" + +import base64 +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .models import Attachment +from .serialization import ( + AttachmentDict, + AttachmentPartDict, + MessageDict, + PartDict, + ReasoningPartDict, + TextPartDict, + ToolCallPartDict, + ToolResultPartDict, +) + + +def _attachment_to_dict(att: Attachment) -> AttachmentDict: + d: Dict[str, Any] = {} + if att.type: + d["type"] = att.type + if att.url: + d["url"] = att.url + if att.path: + d["path"] = att.path + if att.content: + d["content"] = base64.b64encode(att.content).decode("ascii") + return d # type: ignore[return-value] + + +def _attachment_from_dict(d: AttachmentDict) -> Attachment: + raw_content = d.get("content") + content_bytes: Optional[bytes] = None + if isinstance(raw_content, str): + content_bytes = base64.b64decode(raw_content) + return Attachment( + type=d.get("type"), + path=d.get("path"), + url=d.get("url"), + content=content_bytes, + ) + + +@dataclass +class Part: + """Base class for all parts. Role lives on the enclosing Message.""" + + def to_dict(self) -> PartDict: + raise NotImplementedError + + @staticmethod + def from_dict(d: PartDict) -> "Part": + if d["type"] == "text": + return TextPart( + text=d["text"], + provider_metadata=d.get("provider_metadata"), + ) + if d["type"] == "reasoning": + return ReasoningPart( + text=d["text"], + redacted=d.get("redacted", False), + provider_metadata=d.get("provider_metadata"), + ) + if d["type"] == "tool_call": + return ToolCallPart( + name=d["name"], + arguments=d["arguments"], + tool_call_id=d.get("tool_call_id"), + server_executed=d.get("server_executed", False), + provider_metadata=d.get("provider_metadata"), + ) + if d["type"] == "tool_result": + return ToolResultPart( + name=d["name"], + output=d["output"], + tool_call_id=d.get("tool_call_id"), + server_executed=d.get("server_executed", False), + exception=d.get("exception"), + attachments=[ + _attachment_from_dict(a) for a in d.get("attachments", []) + ], + provider_metadata=d.get("provider_metadata"), + ) + if d["type"] == "attachment": + att_dict = d.get("attachment") + attachment = _attachment_from_dict(att_dict) if att_dict else None + return AttachmentPart( + attachment=attachment, + provider_metadata=d.get("provider_metadata"), + ) + raise ValueError(f"Unknown part type: {d['type']!r}") + + +@dataclass +class TextPart(Part): + text: str = "" + provider_metadata: Optional[Dict[str, Any]] = None + + def to_dict(self) -> TextPartDict: + d: Dict[str, Any] = {"type": "text", "text": self.text} + if self.provider_metadata: + d["provider_metadata"] = self.provider_metadata + return d # type: ignore[return-value] + + +@dataclass +class ReasoningPart(Part): + """Reasoning/thinking tokens from the model. + + `redacted=True, text=""` is the marker for the opaque-reasoning + case (OpenAI GPT-5 series, Gemini without `includeThoughts`) where + the provider reports that reasoning happened but withholds the + content. The actual token total lives on response.token_details + (e.g. `reasoning_tokens`); the Part only records the structural + fact that reasoning occurred. + """ + + text: str = "" + redacted: bool = False + provider_metadata: Optional[Dict[str, Any]] = None + + def to_dict(self) -> ReasoningPartDict: + d: Dict[str, Any] = {"type": "reasoning", "text": self.text} + if self.redacted: + d["redacted"] = True + if self.provider_metadata: + d["provider_metadata"] = self.provider_metadata + return d # type: ignore[return-value] + + +@dataclass +class ToolCallPart(Part): + """A request by the model to call a tool. + + `server_executed=True` marks calls the provider executed on the + server (Anthropic web search, Gemini code execution) rather than + the LLM tool framework. + """ + + name: str = "" + arguments: Dict[str, Any] = field(default_factory=dict) + tool_call_id: Optional[str] = None + server_executed: bool = False + provider_metadata: Optional[Dict[str, Any]] = None + + def to_dict(self) -> ToolCallPartDict: + d: Dict[str, Any] = { + "type": "tool_call", + "name": self.name, + "arguments": self.arguments, + } + if self.tool_call_id is not None: + d["tool_call_id"] = self.tool_call_id + if self.server_executed: + d["server_executed"] = True + if self.provider_metadata: + d["provider_metadata"] = self.provider_metadata + return d # type: ignore[return-value] + + +@dataclass +class ToolResultPart(Part): + """The result of a tool call.""" + + name: str = "" + output: str = "" + tool_call_id: Optional[str] = None + server_executed: bool = False + attachments: List[Any] = field(default_factory=list) + exception: Optional[str] = None + provider_metadata: Optional[Dict[str, Any]] = None + + def to_dict(self) -> ToolResultPartDict: + d: Dict[str, Any] = { + "type": "tool_result", + "name": self.name, + "output": self.output, + } + if self.tool_call_id is not None: + d["tool_call_id"] = self.tool_call_id + if self.server_executed: + d["server_executed"] = True + if self.exception is not None: + d["exception"] = self.exception + if self.attachments: + d["attachments"] = [_attachment_to_dict(a) for a in self.attachments] + if self.provider_metadata: + d["provider_metadata"] = self.provider_metadata + return d # type: ignore[return-value] + + +@dataclass +class AttachmentPart(Part): + """An inline attachment (image, audio, file).""" + + attachment: Optional[Attachment] = None + provider_metadata: Optional[Dict[str, Any]] = None + + def to_dict(self) -> AttachmentPartDict: + d: Dict[str, Any] = {"type": "attachment"} + if self.attachment: + d["attachment"] = _attachment_to_dict(self.attachment) + if self.provider_metadata: + d["provider_metadata"] = self.provider_metadata + return d # type: ignore[return-value] + + +@dataclass +class Message: + """A single turn in a conversation: role + list of parts. + + `parts` contains one or more Part objects. `provider_metadata` + carries opaque provider-specific data attached to the message as a + whole; part-level data lives on the individual Part's + `provider_metadata`. + """ + + role: str + parts: List[Part] = field(default_factory=list) + provider_metadata: Optional[Dict[str, Any]] = None + + def to_dict(self) -> MessageDict: + d: Dict[str, Any] = { + "role": self.role, + "parts": [p.to_dict() for p in self.parts], + } + if self.provider_metadata: + d["provider_metadata"] = self.provider_metadata + return d # type: ignore[return-value] + + @staticmethod + def from_dict(d: MessageDict) -> "Message": + return Message( + role=d["role"], + parts=[Part.from_dict(p) for p in d.get("parts", [])], + provider_metadata=d.get("provider_metadata"), + ) + + +def normalize_parts(items: Any) -> List[Part]: + """Normalize helper inputs to a list of Part objects. + + Accepts str (→ TextPart), Attachment (→ AttachmentPart), Part + (passed through), or a list/tuple of those (flattened one level). + """ + out: List[Part] = [] + for item in items: + if isinstance(item, Part): + out.append(item) + elif isinstance(item, str): + out.append(TextPart(text=item)) + elif isinstance(item, Attachment): + out.append(AttachmentPart(attachment=item)) + elif isinstance(item, (list, tuple)): + out.extend(normalize_parts(item)) + else: + raise TypeError(f"Cannot convert {item!r} to an llm Part") + return out + + +def system(*items: Any, provider_metadata: Optional[Dict[str, Any]] = None) -> Message: + "Build a Message with role='system'." + return Message( + role="system", + parts=normalize_parts(items), + provider_metadata=provider_metadata, + ) + + +def user(*items: Any, provider_metadata: Optional[Dict[str, Any]] = None) -> Message: + "Build a Message with role='user'." + return Message( + role="user", + parts=normalize_parts(items), + provider_metadata=provider_metadata, + ) + + +def assistant( + *items: Any, provider_metadata: Optional[Dict[str, Any]] = None +) -> Message: + "Build a Message with role='assistant'." + return Message( + role="assistant", + parts=normalize_parts(items), + provider_metadata=provider_metadata, + ) + + +def tool_message( + *items: Any, provider_metadata: Optional[Dict[str, Any]] = None +) -> Message: + "Build a Message with role='tool' (typically wrapping ToolResultParts)." + return Message( + role="tool", + parts=normalize_parts(items), + provider_metadata=provider_metadata, + ) + + +@dataclass +class StreamEvent: + """A streaming event from a model response. + + `part_index` groups events into parts. When left at its default of + `None`, the framework allocates an index automatically: consecutive + same-family text/reasoning events concatenate, tool-call events + group by `tool_call_id`, and `tool_result` always starts its own + part. Pass an explicit integer only to override the default + grouping (e.g. forcing a single TextPart across non-adjacent text + bursts). + + `redacted=True` (only meaningful on `type="reasoning"` events with + an empty `chunk`) signals that opaque reasoning happened — content + withheld by the provider, token total on response.token_details. + The framework hoists redacted reasoning Parts to the start of the + assembled message regardless of when they were emitted in the + stream, so UIs can render them before the visible content. + + `provider_metadata` carries opaque provider data (Anthropic + `signature`, Gemini `thoughtSignature`, OpenAI `encrypted_content`) + that must be echoed back on the next request; the framework merges + it onto the finalized Part (last non-None wins per top-level key). + + `message_index` is for providers that emit multiple assistant + messages in a single response (Anthropic server-side tool + execution); most plugins leave it at 0. + """ + + type: str # "text" / "reasoning" / "tool_call_name" / + # "tool_call_args" / "tool_result" + chunk: str + part_index: Optional[int] = None + tool_call_id: Optional[str] = None + server_executed: bool = False + tool_name: Optional[str] = None + redacted: bool = False + provider_metadata: Optional[Dict[str, Any]] = None + message_index: int = 0 diff --git a/llm/plugins.py b/llm/plugins.py new file mode 100644 index 0000000..0125ede --- /dev/null +++ b/llm/plugins.py @@ -0,0 +1,50 @@ +import importlib +from importlib import metadata +import os +import pluggy +import sys +from . import hookspecs + +DEFAULT_PLUGINS = ( + "llm.default_plugins.openai_models", + "llm.default_plugins.default_tools", +) + +pm = pluggy.PluginManager("llm") +pm.add_hookspecs(hookspecs) + +LLM_LOAD_PLUGINS = os.environ.get("LLM_LOAD_PLUGINS", None) + +_loaded = False + + +def load_plugins(): + global _loaded + if _loaded: + return + _loaded = True + if not hasattr(sys, "_called_from_test") and LLM_LOAD_PLUGINS is None: + # Only load plugins if not running tests + pm.load_setuptools_entrypoints("llm") + + # Load any plugins specified in LLM_LOAD_PLUGINS") + if LLM_LOAD_PLUGINS is not None: + for package_name in [ + name for name in LLM_LOAD_PLUGINS.split(",") if name.strip() + ]: + try: + distribution = metadata.distribution(package_name) # Updated call + llm_entry_points = [ + ep for ep in distribution.entry_points if ep.group == "llm" + ] + for entry_point in llm_entry_points: + mod = entry_point.load() + pm.register(mod, name=entry_point.name) + # Ensure name can be found in plugin_to_distinfo later: + pm._plugin_distinfo.append((mod, distribution)) # type: ignore + except metadata.PackageNotFoundError: + sys.stderr.write(f"Plugin {package_name} could not be found\n") + + for plugin in DEFAULT_PLUGINS: + mod = importlib.import_module(plugin) + pm.register(mod, plugin) diff --git a/llm/py.typed b/llm/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/llm/serialization.py b/llm/serialization.py new file mode 100644 index 0000000..33d6400 --- /dev/null +++ b/llm/serialization.py @@ -0,0 +1,182 @@ +"""TypedDict spec for the JSON-safe wire form of Part, Message, and Response. + +These are the exact shapes returned by ``Part.to_dict()``, +``Message.to_dict()``, and ``Response.to_dict()`` — and accepted by the +matching ``from_dict`` classmethods. They are the canonical wire format; +use them to annotate any code that reads or writes serialized llm data. + +Example:: + + from llm.serialization import MessageDict + + def save_messages(conn, messages: list[MessageDict]) -> None: + for m in messages: + conn.execute( + "INSERT INTO messages(role, parts_json) VALUES (?, ?)", + (m["role"], json.dumps(m["parts"])), + ) + +Or pair with Pydantic's TypeAdapter for runtime validation:: + + from pydantic import TypeAdapter + from llm.serialization import MessageDict + + msg = TypeAdapter(MessageDict).validate_python(incoming_dict) + +Or export JSON Schema for cross-language consumers:: + + schema = TypeAdapter(MessageDict).json_schema() + +The TypedDicts are erased at runtime — zero overhead. ``NotRequired`` +keys may be absent from a serialized payload; required keys must +always be present. +""" + +from typing import Any, Dict, List, Literal, Union + +# NotRequired moved to typing in 3.11; use typing_extensions for 3.10 +# support. typing_extensions is a transitive dep via pydantic. +from typing_extensions import NotRequired, TypedDict + +__all__ = [ + "AttachmentDict", + "AttachmentPartDict", + "MessageDict", + "PartDict", + "PromptDict", + "ReasoningPartDict", + "ResponseDict", + "TextPartDict", + "ToolCallPartDict", + "ToolResultPartDict", + "UsageDict", +] + + +# ---- Attachment payload (nested inside AttachmentPartDict + tool results) ---- + + +class AttachmentDict(TypedDict, total=False): + """Nested attachment payload. All fields optional — an Attachment + may carry a type, a url, a path, and/or base64-encoded content. + """ + + type: str + url: str + path: str + # base64-encoded bytes when the attachment was constructed with raw + # content= bytes. + content: str + + +# ---- Per-Part TypedDicts (discriminated by the `type` field) ----------------- + + +class TextPartDict(TypedDict): + type: Literal["text"] + text: str + provider_metadata: NotRequired[Dict[str, Any]] + + +class ReasoningPartDict(TypedDict): + type: Literal["reasoning"] + text: str + # `redacted=True` with `text=""` is the marker for opaque + # reasoning (OpenAI GPT-5, Gemini without thoughts). The token + # total lives on response usage, not on the Part. + redacted: NotRequired[bool] + provider_metadata: NotRequired[Dict[str, Any]] + + +class ToolCallPartDict(TypedDict): + type: Literal["tool_call"] + name: str + arguments: Dict[str, Any] + tool_call_id: NotRequired[str] + # True for provider-executed calls (Anthropic web search, Gemini code + # execution). Adapters use this to restore provider-side blocks on + # the next turn. + server_executed: NotRequired[bool] + provider_metadata: NotRequired[Dict[str, Any]] + + +class ToolResultPartDict(TypedDict): + type: Literal["tool_result"] + name: str + output: str + tool_call_id: NotRequired[str] + server_executed: NotRequired[bool] + exception: NotRequired[str] + attachments: NotRequired[List[AttachmentDict]] + provider_metadata: NotRequired[Dict[str, Any]] + + +class AttachmentPartDict(TypedDict): + type: Literal["attachment"] + attachment: NotRequired[AttachmentDict] + provider_metadata: NotRequired[Dict[str, Any]] + + +PartDict = Union[ + TextPartDict, + ReasoningPartDict, + ToolCallPartDict, + ToolResultPartDict, + AttachmentPartDict, +] +"""Discriminated union of Part dict shapes. Use with +``pydantic.TypeAdapter(PartDict)`` to validate / dispatch by ``type``. +""" + + +# ---- Message ---------------------------------------------------------------- + + +class MessageDict(TypedDict): + """JSON-safe form of ``llm.Message``. + + ``role`` is one of "user", "assistant", "system", "tool" in practice + — typed as ``str`` here to leave room for provider-specific values. + """ + + role: str + parts: List[PartDict] + provider_metadata: NotRequired[Dict[str, Any]] + + +# ---- Response + nested shapes ----------------------------------------------- + + +class PromptDict(TypedDict): + """The ``prompt`` sub-dict of ``Response.to_dict()`` — captures the + full input chain that was sent for this turn plus any options that + apply.""" + + messages: List[MessageDict] + options: NotRequired[Dict[str, Any]] + system: NotRequired[str] + + +class UsageDict(TypedDict, total=False): + """Optional usage block on ``ResponseDict``. All fields optional; + providers vary in which they report.""" + + input: int + output: int + details: Dict[str, Any] + + +class ResponseDict(TypedDict): + """JSON-safe form of ``llm.Response`` — everything needed for + ``Response.from_dict`` to rehydrate and ``response.reply()`` to + continue a conversation across a process boundary. + """ + + model: str + prompt: PromptDict + messages: List[MessageDict] + # Audit fields — present on a freshly-serialized response, optional + # on hand-constructed ones. + id: NotRequired[str] + usage: NotRequired[UsageDict] + datetime_utc: NotRequired[str] diff --git a/llm/templates.py b/llm/templates.py new file mode 100644 index 0000000..ac1b7c7 --- /dev/null +++ b/llm/templates.py @@ -0,0 +1,92 @@ +from pydantic import BaseModel, ConfigDict +import string +from typing import Optional, Any, Dict, List, Tuple + + +class AttachmentType(BaseModel): + type: str + value: str + + +class Template(BaseModel): + """A reusable prompt template.""" + + name: str + prompt: Optional[str] = None + system: Optional[str] = None + attachments: Optional[List[str]] = None + attachment_types: Optional[List[AttachmentType]] = None + model: Optional[str] = None + defaults: Optional[Dict[str, Any]] = None + options: Optional[Dict[str, Any]] = None + extract: Optional[bool] = None # For extracting fenced code blocks + extract_last: Optional[bool] = None + schema_object: Optional[dict] = None + fragments: Optional[List[str]] = None + system_fragments: Optional[List[str]] = None + tools: Optional[List[str]] = None + functions: Optional[str] = None + + model_config = ConfigDict(extra="forbid") + + class MissingVariables(Exception): + pass + + def __init__(self, **data): + super().__init__(**data) + # Not a pydantic field to avoid YAML being able to set it + # this controls if Python inline functions code is trusted + self._functions_is_trusted = False + + def evaluate( + self, input: str, params: Optional[Dict[str, Any]] = None + ) -> Tuple[Optional[str], Optional[str]]: + """Evaluate the template with the given input and parameters, returning (prompt, system).""" + params = params or {} + params["input"] = input + if self.defaults: + for k, v in self.defaults.items(): + if k not in params: + params[k] = v + prompt: Optional[str] = None + system: Optional[str] = None + if not self.prompt: + system = self.interpolate(self.system, params) + prompt = input + else: + prompt = self.interpolate(self.prompt, params) + system = self.interpolate(self.system, params) + return prompt, system + + def vars(self) -> set: + """Return the set of variable names used in the prompt and system templates.""" + all_vars = set() + for text in [self.prompt, self.system]: + if not text: + continue + all_vars.update(self.extract_vars(string.Template(text))) + return all_vars + + @classmethod + def interpolate(cls, text: Optional[str], params: Dict[str, Any]) -> Optional[str]: + """Substitute template variables in text with values from params, raising MissingVariables if any are absent.""" + if not text: + return text + # Confirm all variables in text are provided + string_template = string.Template(text) + vars = cls.extract_vars(string_template) + missing = [p for p in vars if p not in params] + if missing: + raise cls.MissingVariables( + "Missing variables: {}".format(", ".join(missing)) + ) + return string_template.substitute(**params) + + @staticmethod + def extract_vars(string_template: string.Template) -> List[str]: + """Extract and return the list of named variable identifiers from a string.Template.""" + return [ + match.group("named") + for match in string_template.pattern.finditer(string_template.template) + if match.group("named") + ] diff --git a/llm/tools.py b/llm/tools.py new file mode 100644 index 0000000..5ac0a7d --- /dev/null +++ b/llm/tools.py @@ -0,0 +1,37 @@ +from datetime import datetime, timezone +from importlib.metadata import version +import time + + +def llm_version() -> str: + "Return the installed version of llm" + return version("llm") + + +def llm_time() -> dict: + "Returns the current time, as local time and UTC" + # Get current times + utc_time = datetime.now(timezone.utc) + local_time = datetime.now() + + # Get timezone information + local_tz_name = time.tzname[time.localtime().tm_isdst] + is_dst = bool(time.localtime().tm_isdst) + + # Calculate offset + offset_seconds = -time.timezone if not is_dst else -time.altzone + offset_hours = offset_seconds // 3600 + offset_minutes = (offset_seconds % 3600) // 60 + + timezone_offset = ( + f"UTC{'+' if offset_hours >= 0 else ''}{offset_hours:02d}:{offset_minutes:02d}" + ) + + return { + "utc_time": utc_time.strftime("%Y-%m-%d %H:%M:%S UTC"), + "utc_time_iso": utc_time.isoformat(), + "local_timezone": local_tz_name, + "local_time": local_time.strftime("%Y-%m-%d %H:%M:%S"), + "timezone_offset": timezone_offset, + "is_dst": is_dst, + } diff --git a/llm/utils.py b/llm/utils.py new file mode 100644 index 0000000..587f192 --- /dev/null +++ b/llm/utils.py @@ -0,0 +1,735 @@ +import click +import hashlib +import httpx +import itertools +import json +import pathlib +import puremagic +import re +import sqlite_utils +import textwrap +from typing import Any, List, Dict, Optional, Tuple, Type +import os +import threading +import time +from typing import Final + +from ulid import ULID + +MIME_TYPE_FIXES = { + "audio/wave": "audio/wav", +} + + +class Fragment(str): + def __new__(cls, content, *args, **kwargs): + # For immutable classes like str, __new__ creates the string object + return super().__new__(cls, content) + + def __init__(self, content, source=""): + # Initialize our custom attributes + self.source = source + + def id(self): + return hashlib.sha256(self.encode("utf-8")).hexdigest() + + +def mimetype_from_string(content) -> Optional[str]: + try: + type_ = puremagic.from_string(content, mime=True) + return MIME_TYPE_FIXES.get(type_, type_) + except puremagic.PureError: + return None + + +def mimetype_from_path(path) -> Optional[str]: + try: + type_ = puremagic.from_file(path, mime=True) + return MIME_TYPE_FIXES.get(type_, type_) + except puremagic.PureError: + return None + + +def dicts_to_table_string( + headings: List[str], dicts: List[Dict[str, str]] +) -> List[str]: + max_lengths = [len(h) for h in headings] + + # Compute maximum length for each column + for d in dicts: + for i, h in enumerate(headings): + if h in d and len(str(d[h])) > max_lengths[i]: + max_lengths[i] = len(str(d[h])) + + # Generate formatted table strings + res = [] + res.append(" ".join(h.ljust(max_lengths[i]) for i, h in enumerate(headings))) + + for d in dicts: + row = [] + for i, h in enumerate(headings): + row.append(str(d.get(h, "")).ljust(max_lengths[i])) + res.append(" ".join(row)) + + return res + + +def remove_dict_none_values(d): + """ + Recursively remove keys with value of None or value of a dict that is all values of None + """ + if not isinstance(d, dict): + return d + new_dict = {} + for key, value in d.items(): + if value is not None: + if isinstance(value, dict): + nested = remove_dict_none_values(value) + if nested: + new_dict[key] = nested + elif isinstance(value, list): + new_dict[key] = [remove_dict_none_values(v) for v in value] + else: + new_dict[key] = value + return new_dict + + +class _LogResponse(httpx.Response): + def iter_bytes(self, *args, **kwargs): + for chunk in super().iter_bytes(*args, **kwargs): + click.echo(chunk.decode(), err=True) + yield chunk + + +class _LogTransport(httpx.BaseTransport): + def __init__(self, transport: httpx.BaseTransport): + self.transport = transport + + def handle_request(self, request: httpx.Request) -> httpx.Response: + response = self.transport.handle_request(request) + return _LogResponse( + status_code=response.status_code, + headers=response.headers, + stream=response.stream, + extensions=response.extensions, + ) + + +def _no_accept_encoding(request: httpx.Request): + request.headers.pop("accept-encoding", None) + + +def _log_response(response: httpx.Response): + request = response.request + click.echo(f"Request: {request.method} {request.url}", err=True) + click.echo(" Headers:", err=True) + for key, value in request.headers.items(): + if key.lower() == "authorization": + value = "[...]" + if key.lower() == "cookie": + value = value.split("=")[0] + "=..." + click.echo(f" {key}: {value}", err=True) + click.echo(" Body:", err=True) + try: + request_body = json.loads(request.content) + click.echo( + textwrap.indent(json.dumps(request_body, indent=2), " "), err=True + ) + except json.JSONDecodeError: + click.echo(textwrap.indent(request.content.decode(), " "), err=True) + click.echo(f"Response: status_code={response.status_code}", err=True) + click.echo(" Headers:", err=True) + for key, value in response.headers.items(): + if key.lower() == "set-cookie": + value = value.split("=")[0] + "=..." + click.echo(f" {key}: {value}", err=True) + click.echo(" Body:", err=True) + + +def logging_client() -> httpx.Client: + return httpx.Client( + transport=_LogTransport(httpx.HTTPTransport()), + event_hooks={"request": [_no_accept_encoding], "response": [_log_response]}, + ) + + +def simplify_usage_dict(d): + # Recursively remove keys with value 0 and empty dictionaries + def remove_empty_and_zero(obj): + if isinstance(obj, dict): + cleaned = { + k: remove_empty_and_zero(v) + for k, v in obj.items() + if v != 0 and v != {} + } + return {k: v for k, v in cleaned.items() if v is not None and v != {}} + return obj + + return remove_empty_and_zero(d) or {} + + +def token_usage_string(input_tokens, output_tokens, token_details) -> str: + bits = [] + if input_tokens is not None: + bits.append(f"{format(input_tokens, ',')} input") + if output_tokens is not None: + bits.append(f"{format(output_tokens, ',')} output") + if token_details: + bits.append(json.dumps(token_details)) + return ", ".join(bits) + + +def extract_fenced_code_block(text: str, last: bool = False) -> Optional[str]: + """ + Extracts and returns Markdown fenced code block found in the given text. + + The function handles fenced code blocks that: + - Use at least three backticks (`). + - May include a language tag immediately after the opening backticks. + - Use more than three backticks as long as the closing fence has the same number. + + If no fenced code block is found, the function returns None. + + Args: + text (str): The input text to search for a fenced code block. + last (bool): Extract the last code block if True, otherwise the first. + + Returns: + Optional[str]: The content of the fenced code block, or None if not found. + """ + # Regex pattern to match fenced code blocks + # - ^ or \n ensures that the fence is at the start of a line + # - (`{3,}) captures the opening backticks (at least three) + # - (\w+)? optionally captures the language tag + # - \n matches the newline after the opening fence + # - (.*?) non-greedy match for the code block content + # - (?P=fence) ensures that the closing fence has the same number of backticks + # - [ ]* allows for optional spaces between the closing fence and newline + # - (?=\n|$) ensures that the closing fence is followed by a newline or end of string + pattern = re.compile( + r"""(?m)^(?P`{3,})(?P\w+)?\n(?P.*?)^(?P=fence)[ ]*(?=\n|$)""", + re.DOTALL, + ) + matches = list(pattern.finditer(text)) + if matches: + match = matches[-1] if last else matches[0] + return match.group("code") + return None + + +def make_schema_id(schema: dict) -> Tuple[str, str]: + schema_json = json.dumps(schema, separators=(",", ":")) + schema_id = hashlib.blake2b(schema_json.encode(), digest_size=16).hexdigest() + return schema_id, schema_json + + +def output_rows_as_json(rows, nl=False, compact=False, json_cols=()): + """ + Output rows as JSON - either newline-delimited or an array + + Parameters: + - rows: Iterable of dictionaries to output + - nl: Boolean, if True, use newline-delimited JSON + - compact: Boolean, if True uses [{"...": "..."}\n {"...": "..."}] format + - json_cols: Iterable of columns that contain JSON + + Yields: + - Stream of strings to be output + """ + current_iter, next_iter = itertools.tee(rows, 2) + next(next_iter, None) + first = True + + for row, next_row in itertools.zip_longest(current_iter, next_iter): + is_last = next_row is None + for col in json_cols: + row[col] = json.loads(row[col]) + + if nl: + # Newline-delimited JSON: one JSON object per line + yield json.dumps(row) + elif compact: + # Compact array format: [{"...": "..."}\n {"...": "..."}] + yield "{firstchar}{serialized}{maybecomma}{lastchar}".format( + firstchar="[" if first else " ", + serialized=json.dumps(row), + maybecomma="," if not is_last else "", + lastchar="]" if is_last else "", + ) + else: + # Pretty-printed array format with indentation + yield "{firstchar}{serialized}{maybecomma}{lastchar}".format( + firstchar="[\n" if first else "", + serialized=textwrap.indent(json.dumps(row, indent=2), " "), + maybecomma="," if not is_last else "", + lastchar="\n]" if is_last else "", + ) + first = False + + if first and not nl: + # We didn't output any rows, so yield the empty list + yield "[]" + + +def resolve_schema_input(db, schema_input, load_template): + # schema_input might be JSON or a filepath or an ID or t:name + if not schema_input: + return + if schema_input.strip().startswith("t:"): + name = schema_input.strip()[2:] + schema_object = None + try: + template = load_template(name) + schema_object = template.schema_object + except ValueError: + raise click.ClickException("Invalid template: {}".format(name)) + if not schema_object: + raise click.ClickException("Template '{}' has no schema".format(name)) + return template.schema_object + if schema_input.strip().startswith("{"): + try: + return json.loads(schema_input) + except ValueError: + pass + if " " in schema_input.strip() or "," in schema_input: + # Treat it as schema DSL + return schema_dsl(schema_input) + # Is it a file on disk? + path = pathlib.Path(schema_input) + if path.exists(): + try: + return json.loads(path.read_text()) + except ValueError: + raise click.ClickException("Schema file contained invalid JSON") + # Last attempt: is it an ID in the DB? + try: + row = db["schemas"].get(schema_input) + return json.loads(row["content"]) + except (sqlite_utils.db.NotFoundError, ValueError): + raise click.BadParameter("Invalid schema") + + +def schema_summary(schema: dict) -> str: + """ + Extract property names from a JSON schema and format them in a + concise way that highlights the array/object structure. + + Args: + schema (dict): A JSON schema dictionary + + Returns: + str: A human-friendly summary of the schema structure + """ + if not schema or not isinstance(schema, dict): + return "" + + schema_type = schema.get("type", "") + + if schema_type == "object": + props = schema.get("properties", {}) + prop_summaries = [] + + for name, prop_schema in props.items(): + prop_type = prop_schema.get("type", "") + + if prop_type == "array": + items = prop_schema.get("items", {}) + items_summary = schema_summary(items) + prop_summaries.append(f"{name}: [{items_summary}]") + elif prop_type == "object": + nested_summary = schema_summary(prop_schema) + prop_summaries.append(f"{name}: {nested_summary}") + else: + prop_summaries.append(name) + + return "{" + ", ".join(prop_summaries) + "}" + + elif schema_type == "array": + items = schema.get("items", {}) + return schema_summary(items) + + return "" + + +def schema_dsl(schema_dsl: str, multi: bool = False) -> Dict[str, Any]: + """ + Build a JSON schema from a concise schema string. + + Args: + schema_dsl: A string representing a schema in the concise format. + Can be comma-separated or newline-separated. + multi: Boolean, return a schema for an "items" array of these + + Returns: + A dictionary representing the JSON schema. + """ + # Type mapping dictionary + type_mapping = { + "int": "integer", + "float": "number", + "bool": "boolean", + "str": "string", + } + + # Initialize the schema dictionary with required elements + json_schema: Dict[str, Any] = {"type": "object", "properties": {}, "required": []} + + # Check if the schema is newline-separated or comma-separated + if "\n" in schema_dsl: + fields = [field.strip() for field in schema_dsl.split("\n") if field.strip()] + else: + fields = [field.strip() for field in schema_dsl.split(",") if field.strip()] + + # Process each field + for field in fields: + # Extract field name, type, and description + if ":" in field: + field_info, description = field.split(":", 1) + description = description.strip() + else: + field_info = field + description = "" + + # Process field name and type + field_parts = field_info.strip().split() + field_name = field_parts[0].strip() + + # Default type is string + field_type = "string" + + # If type is specified, use it + if len(field_parts) > 1: + type_indicator = field_parts[1].strip() + if type_indicator in type_mapping: + field_type = type_mapping[type_indicator] + + # Add field to properties + json_schema["properties"][field_name] = {"type": field_type} + + # Add description if provided + if description: + json_schema["properties"][field_name]["description"] = description + + # Add field to required list + json_schema["required"].append(field_name) + + if multi: + return multi_schema(json_schema) + else: + return json_schema + + +def multi_schema(schema: dict) -> dict: + "Wrap JSON schema in an 'items': [] array" + return { + "type": "object", + "properties": {"items": {"type": "array", "items": schema}}, + "required": ["items"], + } + + +def find_unused_key(item: dict, key: str) -> str: + 'Return unused key, e.g. for {"id": "1"} and key "id" returns "id_"' + while key in item: + key += "_" + return key + + +def truncate_string( + text: str, + max_length: int = 100, + normalize_whitespace: bool = False, + keep_end: bool = False, +) -> str: + """ + Truncate a string to a maximum length, with options to normalize whitespace and keep both start and end. + + Args: + text: The string to truncate + max_length: Maximum length of the result string + normalize_whitespace: If True, replace all whitespace with a single space + keep_end: If True, keep both beginning and end of string + + Returns: + Truncated string + """ + if not text: + return text + + if normalize_whitespace: + text = re.sub(r"\s+", " ", text) + + if len(text) <= max_length: + return text + + # Minimum sensible length for keep_end is 9 characters: "a... z" + min_keep_end_length = 9 + + if keep_end and max_length >= min_keep_end_length: + # Calculate how much text to keep at each end + # Subtract 5 for the "... " separator + cutoff = (max_length - 5) // 2 + return text[:cutoff] + "... " + text[-cutoff:] + else: + # Fall back to simple truncation for very small max_length + return text[: max_length - 3] + "..." + + +def ensure_fragment(db, content): + sql = """ + insert into fragments (hash, content, datetime_utc, source) + values (:hash, :content, datetime('now'), :source) + on conflict(hash) do nothing + """ + hash_id = hashlib.sha256(content.encode("utf-8")).hexdigest() + source = None + if isinstance(content, Fragment): + source = content.source + with db.conn: + db.execute(sql, {"hash": hash_id, "content": content, "source": source}) + return list( + db.query("select id from fragments where hash = :hash", {"hash": hash_id}) + )[0]["id"] + + +def ensure_tool(db, tool): + sql = """ + insert into tools (hash, name, description, input_schema, plugin) + values (:hash, :name, :description, :input_schema, :plugin) + on conflict(hash) do nothing + """ + with db.conn: + db.execute( + sql, + { + "hash": tool.hash(), + "name": tool.name, + "description": tool.description, + "input_schema": json.dumps(tool.input_schema), + "plugin": tool.plugin, + }, + ) + return list( + db.query("select id from tools where hash = :hash", {"hash": tool.hash()}) + )[0]["id"] + + +def maybe_fenced_code(content: str) -> str: + "Return the content as a fenced code block if it looks like code" + is_code = False + if content.count("<") > 10: + is_code = True + if not is_code: + # Are 90% of the lines under 120 chars? + lines = content.splitlines() + if len(lines) > 3: + num_short = sum(1 for line in lines if len(line) < 120) + if num_short / len(lines) > 0.9: + is_code = True + if is_code: + # Find number of backticks not already present + num_backticks = 3 + while "`" * num_backticks in content: + num_backticks += 1 + # Add backticks + content = ( + "\n" + + "`" * num_backticks + + "\n" + + content.strip() + + "\n" + + "`" * num_backticks + ) + return content + + +_plugin_prefix_re = re.compile(r"^[a-zA-Z0-9_-]+:") + + +def has_plugin_prefix(value: str) -> bool: + "Check if value starts with alphanumeric prefix followed by a colon" + return bool(_plugin_prefix_re.match(value)) + + +def _parse_kwargs(arg_str: str) -> Dict[str, Any]: + """Parse key=value pairs where each value is valid JSON.""" + tokens = [] + buf = [] + depth = 0 + in_string = False + string_char = "" + escape = False + + for ch in arg_str: + if in_string: + buf.append(ch) + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == string_char: + in_string = False + else: + if ch in "\"'": + in_string = True + string_char = ch + buf.append(ch) + elif ch in "{[(": + depth += 1 + buf.append(ch) + elif ch in "}])": + depth -= 1 + buf.append(ch) + elif ch == "," and depth == 0: + tokens.append("".join(buf).strip()) + buf = [] + else: + buf.append(ch) + if buf: + tokens.append("".join(buf).strip()) + + kwargs: Dict[str, Any] = {} + for token in tokens: + if not token: + continue + if "=" not in token: + raise ValueError(f"Invalid keyword spec segment: '{token}'") + key, value_str = token.split("=", 1) + key = key.strip() + value_str = value_str.strip() + try: + value = json.loads(value_str) + except json.JSONDecodeError as e: + raise ValueError(f"Value for '{key}' is not valid JSON: {value_str}") from e + kwargs[key] = value + return kwargs + + +def instantiate_from_spec(class_map: Dict[str, Type], spec: str): + """ + Instantiate a class from a specification string with flexible argument formats. + + This function parses a specification string that defines a class name and its + constructor arguments, then instantiates the class using the provided class + mapping. The specification supports multiple argument formats for flexibility. + + Parameters + ---------- + class_map : Dict[str, Type] + A mapping from class names (strings) to their corresponding class objects. + Only classes present in this mapping can be instantiated. + spec : str + A specification string defining the class to instantiate and its arguments. + + Format: "ClassName" or "ClassName(arguments)" + + Supported argument formats: + - Empty: ClassName() - calls constructor with no arguments + - JSON object: ClassName({"key": "value", "other": 42}) - unpacked as **kwargs + - Single JSON value: ClassName("hello") or ClassName([1,2,3]) - passed as single positional argument + - Key-value pairs: ClassName(name="test", count=5, items=[1,2]) - parsed as individual kwargs + where values must be valid JSON + + Returns + ------- + object + An instance of the specified class, constructed with the parsed arguments. + + Raises + ------ + ValueError + If the spec string format is invalid, if the class name is not found in + class_map, if JSON parsing fails, or if argument parsing encounters errors. + """ + m = re.fullmatch(r"\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:\((.*)\))?\s*$", spec) + if not m: + raise ValueError(f"Invalid spec string: '{spec}'") + class_name, arg_body = m.group(1), (m.group(2) or "").strip() + if class_name not in class_map: + raise ValueError(f"Unknown class '{class_name}'") + + cls = class_map[class_name] + + # No arguments at all + if arg_body == "": + return cls() + + # Starts with { -> JSON object to kwargs + if arg_body.lstrip().startswith("{"): + try: + kw = json.loads(arg_body) + except json.JSONDecodeError as e: + raise ValueError("Argument JSON object is not valid JSON") from e + if not isinstance(kw, dict): + raise ValueError("Top-level JSON must be an object when using {} form") + return cls(**kw) + + # Starts with quote / number / [ / t f n for single positional JSON value + if re.match(r'\s*(["\[\d\-]|true|false|null)', arg_body, re.I): + try: + positional_value = json.loads(arg_body) + except json.JSONDecodeError as e: + raise ValueError("Positional argument must be valid JSON") from e + return cls(positional_value) + + # Otherwise treat as key=value pairs + kwargs = _parse_kwargs(arg_body) + return cls(**kwargs) + + +NANOSECS_IN_MILLISECS = 1000000 +TIMESTAMP_LEN = 6 +RANDOMNESS_LEN = 10 + +_lock: Final = threading.Lock() +_last: Optional[bytes] = None # 16-byte last produced ULID + + +def monotonic_ulid() -> ULID: + """ + Return a ULID instance that is guaranteed to be *strictly larger* than every + other ULID returned by this function inside the same process. + + It works the same way the reference JavaScript `monotonicFactory` does: + * If the current call happens in the same millisecond as the previous + one, the 80-bit randomness part is incremented by exactly one. + * As soon as the system clock moves forward, a brand-new ULID with + cryptographically secure randomness is generated. + * If more than 2**80 ULIDs are requested within a single millisecond + an `OverflowError` is raised (practically impossible). + """ + global _last + + now_ms = time.time_ns() // NANOSECS_IN_MILLISECS + + with _lock: + # First call + if _last is None: + _last = _fresh(now_ms) + return ULID(_last) + + # Decode timestamp from the last ULID we handed out + last_ms = int.from_bytes(_last[:TIMESTAMP_LEN], "big") + + # If the millisecond is the same, increment the randomness + if now_ms == last_ms: + rand_int = int.from_bytes(_last[TIMESTAMP_LEN:], "big") + 1 + if rand_int >= 1 << (RANDOMNESS_LEN * 8): + raise OverflowError( + "Randomness overflow: > 2**80 ULIDs requested " + "in one millisecond!" + ) + randomness = rand_int.to_bytes(RANDOMNESS_LEN, "big") + _last = _last[:TIMESTAMP_LEN] + randomness + return ULID(_last) + + # New millisecond, start fresh + _last = _fresh(now_ms) + return ULID(_last) + + +def _fresh(ms: int) -> bytes: + """Build a brand-new 16-byte ULID for the given millisecond.""" + timestamp = int.to_bytes(ms, TIMESTAMP_LEN, "big") + randomness = os.urandom(RANDOMNESS_LEN) + return timestamp + randomness diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..a17287e --- /dev/null +++ b/mypy.ini @@ -0,0 +1,10 @@ +[mypy] + +[mypy-pluggy.*] +ignore_missing_imports = True + +[mypy-click_default_group.*] +ignore_missing_imports = True + +[mypy-sqlite_migrate.*] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..55b7b39 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,86 @@ +[project] +name = "llm" +version = "0.32a3" +description = "CLI utility and Python library for interacting with Large Language Models from organizations like OpenAI, Anthropic and Gemini plus local models installed on your own machine." +readme = { file = "README.md", content-type = "text/markdown" } +authors = [ + { name = "Simon Willison" }, +] +license = "Apache-2.0" +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Text Processing :: Linguistic", + "Topic :: Utilities", +] + +dependencies = [ + "click", + "condense-json>=0.1.3", + "openai>=2.32.0", + "click-default-group>=1.2.3", + "sqlite-utils>=3.37", + "sqlite-migrate>=0.1a2", + "pydantic>=2.0.0", + "PyYAML", + "pluggy", + "python-ulid", + "setuptools", + "pip", + "pyreadline3; sys_platform == 'win32'", + "puremagic", +] + +[dependency-groups] +dev = [ + "build", + "click>=8.2.0", + "pytest", + "numpy", + "pytest-httpx>=0.33.0", + "pytest-asyncio", + "cogapp", + "mypy>=1.10.0", + "black>=26.3.1", + "pytest-recording", + "ruff", + "syrupy", + "types-click", + "types-PyYAML", + "types-setuptools", + "llm-echo==0.3a3", + # docs + "sphinx==7.2.6", + "furo==2023.9.10", + "sphinx-autobuild", + "sphinx-copybutton", + "sphinx-markdown-builder==0.6.8", + "myst-parser", +] + +[project.urls] +Homepage = "https://github.com/simonw/llm" +Documentation = "https://llm.datasette.io/" +Issues = "https://github.com/simonw/llm/issues" +CI = "https://github.com/simonw/llm/actions" +Changelog = "https://github.com/simonw/llm/releases" + +[project.scripts] +llm = "llm.cli:cli" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["llm*"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6a7d170 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_default_fixture_loop_scope = function \ No newline at end of file diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..7a3f6a3 --- /dev/null +++ b/ruff.toml @@ -0,0 +1 @@ +line-length = 160 diff --git a/tests/cassettes/test_openai_responses/test_responses_basic_non_streaming.yaml b/tests/cassettes/test_openai_responses/test_responses_basic_non_streaming.yaml new file mode 100644 index 0000000..1faaee3 --- /dev/null +++ b/tests/cassettes/test_openai_responses/test_responses_basic_non_streaming.yaml @@ -0,0 +1,109 @@ +interactions: +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"Reply + with exactly: pong"}],"model":"gpt-5.5","reasoning":{"effort":"low"},"store":false,"stream":false}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '182' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: !!binary | + H4sIAAAAAAAAA3RU247jIAx971dEPE9HSS9J2l8ZrZADTsuWAAumM9Wo/74KadJmpn1LfPDB9vHh + e5FlTEm2z5jH4HheS9mut0W1LcpNJcu8KHctNEVTQwWiLiCvi2aHq02eY121Tblmbz2Fbf6ioJHG + moBDXHgEQsmhx4qqqvN1VVRlwgIBxdDnCNs5jYRySGpAnA7eRtPX1YIOOISV1soc2D77XmRZljEH + F/R9vsQzauvQs0WWXYeLR8ofV1cJRe9tn2mi1inQevwX0YgLd2hA04Xts/w9T5gyIxmXSKB0eMxU + JpCPgpQ1s3gHX9xGcpE42RP+BslazQXoOV1nJeq+p4Oj5fZ9u1zlq3KZb5ar26T7Ax766x7ThpvY + PvtIoxkGNEnbhcNrZXf1qk7KNtiWuw0IWcntusjLdF9ioYvDxIMhwAHvwCsJEyisITT3oh4Lm9GO + Y8IvmrLTATDGEoyj/fgzA7U9OG+bJ0gi2mfMWXNgE3C9fU1nmTtCSAW0yoDmYMIn+nv93uqEQggq + EBgauHqexMEceNAa9VxK8nHYV+cxoBH4ZKWcx7OyMfDRLTwJNcnpvO0ccQHiiPyEl5eYx37EwzKw + 1eY47IhHCNbMvIJta32airaftx5ZiF0HfmSfvBOgRbpwJXvqVuHMKQH9WQnkpEbvtRD1IBwLZD3O + TEvYuX5fY4oXt/5vCt1qa63v4P7/sBnp3DD1W8ln9I0NKk2TdShV7O6mH3Q4WiUG4SJZNgH3RWFk + HX9Yn3wKuqTRrh4CPhoxOo1JFaDR4xMVkw+mDpSZ+bwo3n7HHx6Pqc8korwn5rNefz4f22fxZ7ST + /q+YyRLoh3rLaYQxzOXukEACQU9/XVz/AwAA//8DAEAnS6QwBgAA + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f74ce61c87211fd-ORD + content-encoding: + - gzip + content-type: + - application/json + date: + - Wed, 06 May 2026 03:12:57 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '1036' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=UYuMLYCou_tA0.A8bkTTDs0INBmHlG2j7U9W9gdY8rQ-1778037177-1.0.1.1-5g.mxYNPGVDfBz2paukjljQbRq5PH1x6hIXpFFHvfnbkZxklDSJMF7lUnwNKNv48F1tAMpQxDGkwbCVoTbZJsbFM324N18q7IanrQ5hcaB8; + path=/; expires=Wed, 06-May-26 03:42:57 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=j9wkRiR3FXF4b0PnFv7DnktylnlM._0WSw8ZUpHPQmQ-1778037177704-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '40000000' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_c6713bbdac8d4639a5ea09cb6c5eb5a9 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_openai_responses/test_responses_basic_streaming.yaml b/tests/cassettes/test_openai_responses/test_responses_basic_streaming.yaml new file mode 100644 index 0000000..2f7e820 --- /dev/null +++ b/tests/cassettes/test_openai_responses/test_responses_basic_streaming.yaml @@ -0,0 +1,136 @@ +interactions: +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"Reply + with exactly: pong"}],"model":"gpt-5.5","reasoning":{"effort":"low"},"store":false,"stream":true}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '181' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: 'event: response.created + + data: {"type":"response.created","response":{"id":"resp_00592e63e61b66660169fab1b9f8e481a2b321356198d7ac1b","object":"response","created_at":1778037177,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5-2026-04-23","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"effort":"low","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + + event: response.in_progress + + data: {"type":"response.in_progress","response":{"id":"resp_00592e63e61b66660169fab1b9f8e481a2b321356198d7ac1b","object":"response","created_at":1778037177,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5-2026-04-23","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"effort":"low","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + + event: response.output_item.added + + data: {"type":"response.output_item.added","item":{"id":"msg_00592e63e61b66660169fab1bada8481a28ef92eb62c168ead","type":"message","status":"in_progress","content":[],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":2} + + + event: response.content_part.added + + data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_00592e63e61b66660169fab1bada8481a28ef92eb62c168ead","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"pong","item_id":"msg_00592e63e61b66660169fab1bada8481a28ef92eb62c168ead","logprobs":[],"obfuscation":"mZhSFjZXJr0X","output_index":0,"sequence_number":4} + + + event: response.output_text.done + + data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_00592e63e61b66660169fab1bada8481a28ef92eb62c168ead","logprobs":[],"output_index":0,"sequence_number":5,"text":"pong"} + + + event: response.content_part.done + + data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_00592e63e61b66660169fab1bada8481a28ef92eb62c168ead","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"pong"},"sequence_number":6} + + + event: response.output_item.done + + data: {"type":"response.output_item.done","item":{"id":"msg_00592e63e61b66660169fab1bada8481a28ef92eb62c168ead","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"pong"}],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":7} + + + event: response.completed + + data: {"type":"response.completed","response":{"id":"resp_00592e63e61b66660169fab1b9f8e481a2b321356198d7ac1b","object":"response","created_at":1778037177,"status":"completed","background":false,"completed_at":1778037178,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5-2026-04-23","moderation":null,"output":[{"id":"msg_00592e63e61b66660169fab1bada8481a28ef92eb62c168ead","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"pong"}],"phase":"final_answer","role":"assistant"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"effort":"low","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":11,"input_tokens_details":{"cached_tokens":0},"output_tokens":5,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":16},"user":null,"metadata":{}},"sequence_number":8} + + + ' + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f74ce69fcf0cb75-DFW + content-type: + - text/event-stream; charset=utf-8 + date: + - Wed, 06 May 2026 03:12:58 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '512' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=Xlm1i2d4hNcY9FYDrgbksx5Ak.InLICBWuWA7k.o5eU-1778037177.9137826-1.0.1.1-GD76qNB2IKnNrhlsjKWgXNF6BpybgPQmNoOTYCMh4ejIhEDWMMph.l_WZvek4hrh5qeLqHfJ28EH5H2c10KBdqGhET8jLthIUOa15fXX7oxYNG_MZ.CHZ9Fuj4bT9GU.; + HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 06 May 2026 + 03:42:58 GMT + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '39999826' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_445d87d531af499daeb09f7826886b8c + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_openai_responses/test_responses_interleaved_reasoning_between_tool_calls.yaml b/tests/cassettes/test_openai_responses/test_responses_interleaved_reasoning_between_tool_calls.yaml new file mode 100644 index 0000000..ec69270 --- /dev/null +++ b/tests/cassettes/test_openai_responses/test_responses_interleaved_reasoning_between_tool_calls.yaml @@ -0,0 +1,540 @@ +interactions: +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"Solve + this puzzle: call db_lookup(''start''), then follow each instruction step by + step. Each lookup tells you the next key to use. Compute each step in your head. + State only the final integer."}],"model":"gpt-5.5","reasoning":{"effort":"high"},"store":false,"stream":false,"tools":[{"type":"function","name":"db_lookup","description":"Look + up a value by key in the puzzle database.","parameters":{"properties":{"key":{"type":"string"}},"required":["key"],"type":"object"}}]}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '551' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: !!binary | + H4sIAAAAAAAA/31WWbOaShB+z6+wfA4pQNb7prKouCKuNylqgGGR1ZnBLXX++wXMUc9Jcn2woD+6 + p6e7Z77v55dWqx157X9abQRxYdMcK7uM7/q86/igw9OMIPvA8SHf4UVXYmSxehYAgILvcLzvO3L7 + ax0idw7QJe9h8gzDu91FEBDo2aDGGFGUaI6WaL7BMAGkxLWPm6dFAqvv7k4OcOMA5WVW5+WDBMO7 + OUqSKAsq28/qtTIU4ApR7e/BE0zyonqpgLf7wu8hPy0tNihEKK89szJJGoOP4LGEmXu1C5iBhFwr + kP5GN1iUvQezPUhAlOBXzyjDBJUuiapNv9pTcLHzkhQlsUkew99BkueJ7YLkY7g092BS7ykoCMV/ + 4ymWZgWK5ii20358gEC93KvbfaXK8m9TmnuBnq3Ff2+swAK5aazMcIIoQiADlmM5QDfLNUHItYD3 + 1gKcZ3ULHlBVMnQt6jK7eUZg1oxA0K1/vYJCdqaommIXY0+YqLd81telmQHjI6WepFPmzK14GUHO + zE99E2+Zsot2OKWvhnHtdtVdKBHztl8bKx8anjCwdupRR+cVHMpTTzQYrtQQve5LdlfVZlveNtPo + Fo6G2cGfjHv8BbHCMhg6y7i3K9Sxvrvl3IDKku5ofc5sK8giygD8aXoY7XHIUWf+vNouOlNgcAfa + 2Y52ypkYWrLgk/FtDjgjuMWbUSzQg4shceKe369jcYMH8tJQlPlyrGKXpNvLjj+Wo1jerpb6lI9N + 2pobOnaHF2tLbsp84JZeP+oePFM/0YFtDUfJcLUQcl/ZbhaJ5HjFLj8uho567RjhMezE2MiXushk + 9AqvzpTg0UPYmyxSmlu7etRfhcoAGKCjhtol5CfTIC3X6kBZFlzveobrsyHqnJ5FhidNTzEotas5 + W7Cz9JgdbGFigI3rDsFinfK7s7xZ0fZ8ONeUmXHFUHRTzJLkqhfaBSqBFSy7Jr6MwV7dT4YbN9pb + jAJuXXmMkMJ0QF/h4Hyw6Zsjp3BiZSBqk47FWshiBEqn435U2LNtKmv7/WV06gar7cTJ1ipmlmWw + 6fXZzOr7OzQ2z9vD9baPOzyMbydhPLyK+ygIaULZpjXboF48vXQp8XAK+nN7p/GMqzNZzoWWpg7C + 0C3Pu2U6WU4m3djdRLQSUtrWuYqxTA4rfWJYB8yYU3k6lIjLJstz2lW9cO/F9qhQs3XiF4qKD6Zm + DlXB9INQo5ZmxgZad+Wb7g0OjCJJyWZCQ8WIgCFsjYE1XwCC6fTG9BgBJOZopkLNOU/miy4zud5E + QAxH2vNDe4gSvRcyg1x2SrCzTc3vFzpM5+5MhibS8724GJ+Xi4FQnJkJUkq8twvCnYxdv3NUyg5S + 97YwG5y1XWbO1wd7UXIOkX0eIGNuGd2CDVMV74y+cgIb2CvYJFBnjiDgU4c7DoQjpvK8353pIkrJ + YTnur0tJo02GmUaaG51PaZKq/dnZkj0BpoFxyGSLs9l+D6zSXEaeGk39I+Wm4UQXASscdHDDw0Qe + 3qgClRcxXEuSyES7ZKqEmT1BkhDH/TTOjDHfIbpDsfLWNaiDtcqSsY4GaZj1Elro+7RPuaqILUFh + yQG4CgzluRCQkoZZz5875Wgg8nmX5ehBZKzEwOx5VLzLzkJ4CvsTYxUfHElaHXhmps0uPfqkjDON + s4WrhPD5usnnawhLc7IZjEhuQ4TNZVRustv6MOrmnGfIk7HfM8xwSEJho/RKwYRjlhf5hX9b7VTN + 0repPt92OrP1mabSIH1edbhMU4BqYvj3R2N7+/qnu9Z3/37XirTHSPVdK/kCBB4nsjIPKkZlf79r + /TJrOKVhiJck/sKZDQhQUKbVHdzgP7+3Y3j93v7ne+2EyPf22/PLOqh9z7d5HAoinXZvptDd4ZuX + H8FQFo4BHT89MpA2eXmOneR5XBbtew2q/x8NAxUAVZFg8pHXKmq8k3dR6YKKLOAf+LWCTlFeYvtd + Otwze3BbgaqtkiqkG0K72tNfMQRrBrozY5vlwjthPlnrIRyg7+eooaqwumN+bfKlwXX4h5LAwIfk + WuVUx/Yj+EE3YIhOUbUrEr0rER+UCWn/Ejg5gh8kDIFpUbN32diZXwUg8EKeyVWppeD5/jITzXev + o9c+QeTkOGrK2U6hF5XpUwLdGxHmVX41DEqStx8A/l0rfB69Z/M9iF0UFe+lHVcD0CqLFmidQFLC + lnNtVW1pRVmLhLBVlLdbAlseIMABGH77vxl6QPX0pFX7EH7Z+L2/VcGq6n60V8h9EF5NL1uoJFnd + 8Bfw7fH89vUZvlZ9EYLeoxgvwR/vP14c3hf4pXdfEOB5UV0gkMxfc256/+XTyk1+jVyuz8eng0Ty + oipPUO3cqQPQD2PRnBlZuhtQ1aV3Gdj2Igyc5F0/lxgE8DlQUfZBhErS19/tL8r25/OSqA6V93Sk + P4zeZ20r8H8C/hT3cSCfzhz/ITbJCUieKMN3HkNd4o8HsBoaUI9avcDbl7f/ADPaWJDQDAAA + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f7526f32c3c0044-ORD + content-encoding: + - gzip + content-type: + - application/json + date: + - Wed, 06 May 2026 04:13:27 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '2091' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=N7TAdwEKwrRihZ4HPzOd8tAdwT5JnZWp1Mj2.UfCC3g-1778040804.348554-1.0.1.1-NFuNpo0J7tlz8j4YDeoThOIY2CDX.4CkvnAcV4AwMaERfiFLSTPjDY8gcSPYiwIXP3TeplBxIS2vJK_VrxoTlp4TAVqwN8bgJ6CMnQFDfKuS7S0zRVqTDUsqYdgW6ccN; + HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 06 May 2026 + 04:43:27 GMT + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '40000000' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_c48ea45fc57a407bb6b100db8c59af68 + status: + code: 200 + message: OK +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"Solve + this puzzle: call db_lookup(''start''), then follow each instruction step by + step. Each lookup tells you the next key to use. Compute each step in your head. + State only the final integer."},{"type":"reasoning","id":"rs_0429c1fcf5cbfa350169fabfe62a9c8197914677ea9a2424a0","encrypted_content":"gAAAAABp-r_nDEFD_pLd6MEzoOCG8OKekq-Ev8vnbPTkSie4RovCRsX1uArYsm0yKKyAAEYh8tRzZVKUfeKd6HTYEqGrwUeI9Nd7K14uFr0VC8_AEFOX5_RmizhJInjfMLB5xr26SgIbSkBYpELGYzo4H-nlAJVwn_Tgni-Ka5vNjJZsh4-w5wUXQ3NaK4j0bXJYDwtKFlQ5lLzPa4KgzkWJk60HxK847Z5ZVk7WsH9SKDDPSLEsctmXxY5quJk9XUSGN5kR0TPKGscIxTXtzDPHcudCiAjdRGv0g_TIJlIUQ6ofDXWQl8bdpYoqQIbEy3Khqh3ksKoSG71n0UsUw-6d0IeBMQm04VcGiCUhDHaKa3EhFxh5MNgmuVEHDSp4ByweVwK7G4GniKd8NvkauFyROQ2Omqnj_6MKaWccIaQVm5Yw9WU0_PIPFDOKyse7cms2tlyGpFxeDgTgSARsxLaZEZMIWciZT1DazA9LrrD13aCD4ePHWCRJbpbkDH7FM3T2TrT16-G0kCip_OXm9FZZxJvAgUXMbnVEs1SugWBC2nTCfYrLRwXjyzZk35ekzv6LIy7Zigh0t-_RTOWrBkNxA-7jvgCP_YF51cG1no4hTFEHhhcuwYSmMSMMAkcWi0Dh-FXby7k9tjUGMKTjs1RN9NI8tc2lSwmAEdhZdk_JpEnVlfpDEsjRFRIE6RfghF-SRn2gFAUfRczeHKplmtWM0eDKiaK6XKHTPQats0mz1B16alRJOEeFbwMPQA1Myz7atKb8Z5I_IrlGBh1Ho9buaY_RFfCpGemPcO9eRrGoZ7QLwSQH6pw1MrDusZ_pt4vKYC3qDu3rEZ_6OHwFYnRPVj_Qu4bt9f5arKPTKAp2hmEsYKCDvaWeBp2lgEOb66sv34qH6qs-ooCAOG7rmtjSLCVu8F0R11NiFciwvmlmECOwT9d6emgKjn9T4_2CBaUmo9rdEiNfq-cmhMG7a26jGazsIl9Iz-prux7hV8871iYlNDhn_Mr86kkCmknKL53tGb-29XcK-jTUnlLGrHmhnBl06Cf0f-cE7sT6D2tjacDeh9P6gtu0enBfPbuJH75oA240HiKU7gRBd-kYnw6hvhCMKUkjb88Uj51OFOxB0vDLnF4_6y8rswyWoPVeeuRMWHJto_ersRSiuWnzVjJAo4dK9MLfBKRhIth6WDBu6ReL2575QfzUYEFTGXmGPX33OVw0-mgm","summary":[]},{"type":"function_call","call_id":"call_I670mAzR6AYszdoqaI96qg0k","name":"db_lookup","arguments":"{\"key\": + \"start\"}"},{"type":"function_call_output","call_id":"call_I670mAzR6AYszdoqaI96qg0k","output":"Begin + with the value 7."}],"model":"gpt-5.5","reasoning":{"effort":"high"},"store":false,"stream":false,"tools":[{"type":"function","name":"db_lookup","description":"Look + up a value by key in the puzzle database.","parameters":{"properties":{"key":{"type":"string"}},"required":["key"],"type":"object"}}]}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '2127' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: !!binary | + H4sIAAAAAAAA/31XWbOiyBJ+719xwudmApR1IuZBERBRERG36QmigGKTtQABO85/v6B91NPL5cGg + 8qvMyq3Mj+9f3t4GgTP4+22AYJGZODnkbMK1Xcq2XDCicILmXGC5kOFYhmUJjuFI3GHhELcIAto4 + 6Qy+9iZSK4R2+WEmTQp4l9sIghI6JugxgmFYnMRZnLlhRQnKquh17DTOItjtuytZwD57KK2S3i8X + RAW8i4MoChKvk33vlp0gAy1Evb4DLzBKs27RAe/3gz9Mfj6awG8oRCjtNZMqim4CF8G8gondmhlM + QFS2HYj/dd8cJB/GTAeWIIiKV80gKUpU2WXQBf0qj0FjplWZVaVZpmf4K1imaWTaIPpsLk4dGPUx + eVmJUX9R2BAf0hhOYsPR4LEBgf64V7X7SZ3k31tq7gl6lrb4c2HZEcneCsv2DwDAtYbAItx7LW5G + yjaD99KCIk36EjygLmWozfo022lSwuTWAt64fyYZhswcKDNWwsY4yyrr2Bwr5517viLBrLlQCpxl + vvBH7iEFNSdb5VjnprVvmCqf+F4ocuV0pNB0E6Md6e0u0wMVu2FYiOSYRYgrnOO6XNbENrbrZVJO + dsplXpyVMAtPkC4op6iTK3+tjGsl0YaqZJssPWVJzMuwpvQYO6XNEV+H5eEYMubRuZ6vh9hil0Ri + iW0be3KUgilAMDoihhCxui1acyI367ErVpHN8HBrhtKRzrchdgAHIVzBSzY+bxqtGOVTsXCJy3w+ + 15cbo6IuM1HFzrFmXHiCZ5KVflawsdaIpOc3Q9m0pflKn1VFEko5JYMRx8xMnJ+oWrh1iNlcrkbG + XmK9aqmHMy4ewmixFI3MFZqLtSEDBYswWPP2PpNbd984iz2ajuR9HmuU7LCJIM9FhWHjkNzFi3XN + ztX1eYwSW7e9Q75V6KqLWzgMZ2WxEvhG3FA1AXcnnWiDRqBVvVOjrth0GjVNg2/TwBoTcy63C0pT + NhhuLBpzoZhrz+XGUGHN01DMZ6tE9KlE2e2dVj4emm1WoUwkciFRJrzO10BjDldjHs2xhDBE71Ti + xka0Cis/jg6Fz5MVQpe8qNc5sV5Mcl+zT83aKg75UrmKVb2l5zQvREMJGSkfzqzdNJd9IqQjm5pM + jwED1PExYSyh1mSz9N1YswJGQGB3WsaeJSSX47m5XOGaYcc6Tx6DY+3JV6kyNOVa5xtFsZoM0inc + CQwn8vDgC5ayHZ7wsz056UYaN67OB4tyteTxVcDtRddQK46MNuV27axnbLarMG0iwmvASm6jHmar + fFWJ+k42d1p3M+R0TQaAgMfGkod+Vo+j86hiHUPnL/NWdxaLmnPW+5XW2ClFXKcgbrWdAhMrEayQ + POWnjRJhw9JMoISP7QOVRwGfT8dknMcLhmwDhOJjSXNnWdRoXeLXGEuf4xGIvbGcqIU62emagmEz + oF4mhru1MX6/UNVV7oZDcDCCJa9c9OUl5TBarDySL+SDY3tNrmpHybUpNbMhG28NB+lh7FvEPliq + dHTGFzqa5bhsFLLPThPuQFl+V8WEdXiqjpgjYCap23pDYSJtU1K3mQBL3OHEFQ/MOd/GqrGRABmc + IOuvsiptS283jaywjVx81RqYJOBeXVmiF1v0NE8Yw5lDRzADcqrIdlllQZZBO64zT+V2RBJE2+iY + ro4hklAbbjcneiyEbArgZrgZSSRRG9lydqEroz1uZSUioqE9dmhbwj1qzAq8x1maKxv2eGxrq5Hg + uKmcqgdVyPklFvNCGK65WrMb8lquPNxMze2W5vOTt1am1+uepYzOda/cS75B0qS+Foz02MwRn+2D + JtdMdzteOSJZJOuRv5FIpgWVDBPRUUfEtQKuOkd2LkxbW3Gj6ewypo6GGihEoKZThQSW5p8mcB1n + +1YVXTaq3MJYSCks82Ahkbsl5TtBKOdnWd0HTqDok2VzYA8MZY3hlqr0UcLndKLj4WaxVebMYsHl + MxqPoYNxkntdNsohWc4CvIW1t4s11asDRKKCTYdVGRa+hVQ4vZLYCSMzvjjXp9wR8iOu/fPPcwQU + VRwD1A/Mf/+7yd6//m4GufafZxAYWjbZzyBAUCTrOC5OugRLctSvM8itktusvU3OFyf+wCVuIEBe + FXez6YZ//zY4w/bb4O9vA+bb4P25qzdo3n29vRohhdOBoIST0x7N1vmQH0nT0kNPjQTEN58cy4zS + 9Fxlg3v83e9/t6mcAdRZgtHnWd/RhTuhyTqu1A1Q+BvO0UGXIK0K84NO3T17zPsMdWGWnUnbh2YX + zx8xBPupfGcLgyHp30nEc5I/yBR03RTdxrcfeP6PIF+K25t/sKsCuLBsO596224AP3GpAqJL0EVV + Bh/szAVVVA5+kL4UwU+0roRx1jOa6iYnfiSghE35dK5zLQbP9Us/3Pa9tt3gApGVFsEtnYOu0YMq + ftLCeyH8tPOvh0FVpoMHUPzKn35uu2fxHVjYKMg+UrvoGuCtyt7A2wVEFXyz2reuLG9B8lb68C2r + rtcIvjmgBBYo4F//r4ceUN89cVc+VLwEfq9vl7Auu5/lHXJvhFfRSwgdTe0L/gK+P97fvz7N90w4 + QNB5JOPF+GP934vCxwE/vgFeEOA4QZ8gEK1ffb7V/stPJ9/8u31C9Pfjp4tUplmXHq+L3OoN4A9h + drszHHsXoK5KH9R44AQFsKKPb4qqAB58NlSQfCLmBEN8/RV4ofvfn/8S3a1ynpr4p977mfATBPs7 + 5HeGH1fyqc2xn4yXaQmiJzpkuUdbV8XnK9i1DeibrT/g/cv7/wBdVsXR5g0AAA== + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f7527072b8a0044-ORD + content-encoding: + - gzip + content-type: + - application/json + date: + - Wed, 06 May 2026 04:13:30 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '2766' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=DRZquxDLKhKw.kobZpdPpYdyAORTxb8d3bk6OV6L_3g-1778040807.5506942-1.0.1.1-C63sdNCp_e3AcsWa9mPoKJLTMlDCSogmc69aY3uzn3tTS0hpqMcaswl4Vlx9jLN79I4aGoL9PKji0tX10HxiuZxf4oEWmMjN_QopXLRhPxlCyZuOq26Bg0WQvU0arTlt; + HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 06 May 2026 + 04:43:30 GMT + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '40000000' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_af2b96dc465344fbaecb33d287edd1c1 + status: + code: 200 + message: OK +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"Solve + this puzzle: call db_lookup(''start''), then follow each instruction step by + step. Each lookup tells you the next key to use. Compute each step in your head. + State only the final integer."},{"type":"reasoning","id":"rs_0429c1fcf5cbfa350169fabfe62a9c8197914677ea9a2424a0","encrypted_content":"gAAAAABp-r_nDEFD_pLd6MEzoOCG8OKekq-Ev8vnbPTkSie4RovCRsX1uArYsm0yKKyAAEYh8tRzZVKUfeKd6HTYEqGrwUeI9Nd7K14uFr0VC8_AEFOX5_RmizhJInjfMLB5xr26SgIbSkBYpELGYzo4H-nlAJVwn_Tgni-Ka5vNjJZsh4-w5wUXQ3NaK4j0bXJYDwtKFlQ5lLzPa4KgzkWJk60HxK847Z5ZVk7WsH9SKDDPSLEsctmXxY5quJk9XUSGN5kR0TPKGscIxTXtzDPHcudCiAjdRGv0g_TIJlIUQ6ofDXWQl8bdpYoqQIbEy3Khqh3ksKoSG71n0UsUw-6d0IeBMQm04VcGiCUhDHaKa3EhFxh5MNgmuVEHDSp4ByweVwK7G4GniKd8NvkauFyROQ2Omqnj_6MKaWccIaQVm5Yw9WU0_PIPFDOKyse7cms2tlyGpFxeDgTgSARsxLaZEZMIWciZT1DazA9LrrD13aCD4ePHWCRJbpbkDH7FM3T2TrT16-G0kCip_OXm9FZZxJvAgUXMbnVEs1SugWBC2nTCfYrLRwXjyzZk35ekzv6LIy7Zigh0t-_RTOWrBkNxA-7jvgCP_YF51cG1no4hTFEHhhcuwYSmMSMMAkcWi0Dh-FXby7k9tjUGMKTjs1RN9NI8tc2lSwmAEdhZdk_JpEnVlfpDEsjRFRIE6RfghF-SRn2gFAUfRczeHKplmtWM0eDKiaK6XKHTPQats0mz1B16alRJOEeFbwMPQA1Myz7atKb8Z5I_IrlGBh1Ho9buaY_RFfCpGemPcO9eRrGoZ7QLwSQH6pw1MrDusZ_pt4vKYC3qDu3rEZ_6OHwFYnRPVj_Qu4bt9f5arKPTKAp2hmEsYKCDvaWeBp2lgEOb66sv34qH6qs-ooCAOG7rmtjSLCVu8F0R11NiFciwvmlmECOwT9d6emgKjn9T4_2CBaUmo9rdEiNfq-cmhMG7a26jGazsIl9Iz-prux7hV8871iYlNDhn_Mr86kkCmknKL53tGb-29XcK-jTUnlLGrHmhnBl06Cf0f-cE7sT6D2tjacDeh9P6gtu0enBfPbuJH75oA240HiKU7gRBd-kYnw6hvhCMKUkjb88Uj51OFOxB0vDLnF4_6y8rswyWoPVeeuRMWHJto_ersRSiuWnzVjJAo4dK9MLfBKRhIth6WDBu6ReL2575QfzUYEFTGXmGPX33OVw0-mgm","summary":[]},{"type":"function_call","call_id":"call_I670mAzR6AYszdoqaI96qg0k","name":"db_lookup","arguments":"{\"key\": + \"start\"}"},{"type":"function_call_output","call_id":"call_I670mAzR6AYszdoqaI96qg0k","output":"Begin + with the value 7."},{"type":"reasoning","id":"rs_0429c1fcf5cbfa350169fabfe83488819788888aaafb2ab1fd","encrypted_content":"gAAAAABp-r_qaKH8G-A088KPm_AKkVfkzrE_w9jGidMqLh3fXoaw9IbtAS9DwhU_OCnhgjF9tD3K66xmrV4gVvDX5mfjjsF4A8rr9sdYPtMw1TmcwMntBVKvJskKjpjZe6s5dswnzCzuUzuG6UOKpRpoZpnmCIew5Sm-ZoxY0PjtXYj7_YdzkzXmb8M1nbFyymgIloaDarelYr71F-wysy_BIxPAfFulc7CeT_jGY6qTj-XaXEjNevpAkRxQs3qDFsf1vJJJSMRUu5vHFO-kmQUvC1C7nNSkK-AQxF4ghx2I_cGJNSHusnjGq5Ia397H_0CBOQjTd1HJIu3UWG8guMSjH9m2elLMFUpfExvbR4iK-l-ewCcWpIyfWxdLWrD3IWqmQ5Id8nEIJFK78mj4VmLPw8JOPkArncScgXqTK6uzkzEX2HtsNECxFR5w1eVZS1yixE6OSj4V5z-DDlxxx0ToibA1J9qcs5QKR-0ULx_LK_Pgf9AeK8_Z2FqHNnFh5nKVWdyIYXxTpurpF1qEnKBCSCwaQ7XzUJlJ-n1UFgZt0URFbsbqY3XshC4urrvqswPq1PLBqhQcZxPbsXqMKzFuwT6J6CEl2GrUoCjHbVDqIh1j6lc5BDYi7aOAYn7bEwQI_thfmQbi7EraVZMmgbEnvYkxvzeP78ASC4YiYwgIzGuUQKzwqRKKbxpe6oeVE79FCeXhEbKT2Z0kcBZSUomxfSCiLtNMC0Ni9WFfUOu94lRtTPdPH8pVu-QBFezi8GfxOXHNqNuFSVI_VQtenIoP4ia1eYxbI2hpwAlk3u8dUSCvJySdLLw9dPWNQxco51zDamyQVKenbnEbj4ZqZRKl-2t_neG0AcX5qliCqDA4mqmL74yirrmYt69kIFQ6SGCP-86km3amgAInOsOBVSQK--HaOvBUfTc-CWLOONqfj2aXUiMCKvSMvo9-6Fug4CsIXdcgxqOQYGfc5Opce8mTUdrSjmhb1WiMO6lk0LSrHq0IUsIh8Dn9X5bhXshn8dC5wl7Ya7Bofyg2EBGTo4Sc7i-nf2BfFX7kqTmOURGa4iZe8hNpuoytgVDlbjylf0NyU-GE0gwubFgmb6Dqn7UdJedE_i4DKIctupippecmwpgO9V1nilTlYoNYjrGryjTRZ6AEj8oaeR2R3G41wUpMHv6uUyYTIKl1l2cAd6cG0g5A8ECg9bQfIUcAAcQN3EdfoIoOXOEqCM-mCEjjP9wQcx4ztNg0_o_TT6CqZgPKDzzW85UgVDgtWGhU464SPEUoYxJrCpWixqQ_fTANdF4snP3hRG47yauIenFdO31zuafOJrcqEDycKflDHvA5YUOiK1iOoDK4abQhZBePmpWyOFf8lufsULGoetqiLG4VM5hdijIqkIOWidiKSBMxX8X75bAeT5uS3nCq6nS0jRLTKJ7LL9qH60med-9GfzMxKXnMHi0yewgVmQOgwir4rs8o2utjshbrOeDz4-Z-4pCskwZqdEqY0Q==","summary":[]},{"type":"function_call","call_id":"call_Uj506iEKjBZWrHPq2C3GDtgr","name":"db_lookup","arguments":"{\"key\": + \"7\"}"},{"type":"function_call_output","call_id":"call_Uj506iEKjBZWrHPq2C3GDtgr","output":"unknown + key"}],"model":"gpt-5.5","reasoning":{"effort":"high"},"store":false,"stream":false,"tools":[{"type":"function","name":"db_lookup","description":"Look + up a value by key in the puzzle database.","parameters":{"properties":{"key":{"type":"string"}},"required":["key"],"type":"object"}}]}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '3967' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: !!binary | + H4sIAAAAAAAA/31XWbOiyBJ+n19x4jwPE4CsE3EfVAQERRQQ8M4EwVIg+1Yg0NH//aLO2bp7rg8G + lV9lVm4FX3777eXlNQ5e/3x5bUBbOSiBsz4W+iHpe6G7IFGMYkPXC4HLhB7BYCztoQuMoFHCoxkG + xQPq9fe7idJLgA/fzJRFC55yvwEuBIHj3jGMphmUQBkMfWAtdGHX3nX8Mq8yMO97Knmun0ZN2RV3 + v0I3a8FTHGdZXESz7Nu8nAWVO4Lmrh+AHmRlNS9m4Pvz4DeTPxxNPlDQNOVds+iy7CEIG1B3oPBH + pwKFm8FxBtE/nn7GxZsxJwDQjbP2s2ZctLDpfBjPQX+W5+7glB2sOujAMgU/g7AsM8d3s6/m8jIA + 2T2mqIII+QeJ4ChOISiB4IvX9w2Nez/us9rzpFny30dqngn6KG3774X1SIp9FJah0YUbMiS5cFF6 + 4bGP4x5G4FiBZ2ndtizuJXiH5pQ1Y3VPs18WEBSPFoiW99+qQhqn5wVfM48cyiwmUmpril7nMss6 + l7oubmumoBIiPYfmVRFJia2kGwno7owduyYIEtHGbvQq8M6E2IR6OhGaIcYl5fJA3BuNdzx4XHy6 + qUwqWrik2Yt8cbNca7kqSKeWYobcJfSaoyx6kUx1cKw2QDgi5LRbSO1wQFPS6C4eikFy002dMN0c + dHfC9CxGDelGLBo5Wm9qtxH1rl1k1CLAmMrqZPK0X52QxcUZtuy8B9yGJtVdEeNW0QCzAXLkhQiR + AEVOXihghNbXyFFb3NowIq2mYEhZEm8hM0okuadJfDmslk3vxHV7xuxOvixvIz/oqBjvGu5w2zqC + cxztkENQAxHbswA22lpDIiK5cSpntRB3JVU/jFeodyjnVdZoOEbqNTC7tcThcOmaDDHpXd9bmSkp + AmqaCZVXa19szP3UeUJ61ESG9hHB2mhiuQqG4QQsocy5lojqwlvRgV1FyknpgT3tCul0DDg7DWxR + l7Z0tNqcFX51I282Olmnqw3j/uoIhNLtcN72dbVhJr4d6q1hjTtTwhGWVNCk1xy3Ik6o6GSkbTPw + Jvc60UJ7uWB1XJUhOndHxqLU2SpdKJmJyY7hLimwcAvwfGE2xNm6pqaR0z29JAfySBsHL77CZYGQ + yrU02JWM8f5+Yzuoket+Ziz3iMafm92CgyErwNIudyKjGh6hBdWVHVGO8tyzphSragzOGJOxFoya + ThPJCoWsD4bZsWnSl/neY7uEn5eeU5sXZ8VrfCGVNJ3Z6zKWTaCAzT5b2KVJQKvOyzao6kzjK1Fu + 3AGxBbDnsMKNlPrIbwttByJ/o9VbJkOEsZ5LZS3OwmA6iSpIUXJ1bpeledTlfSz70F8iS5WUDpU0 + uOp0ia6nvVAFW3pRHA762UmO7OGcyrIJEx7su6Djr+YOxrF/bU1TqxnrJnGGrJ9G29nZq2FSeX7Y + Xoe0PuK05xSBpO76kdPZpd4rDEnlgybZIJPRTkqGOsEWB/JibtcVWgWGEG92EYoeMdLYruos4449 + jTk7WKROqW2qxjplXhzy7Y1ndOXMe714JS+84ybAUjwt2oetU2uQFbsLh8PRa7XWFus8hpXboF3g + R4rcj7Eqq5brLdJbrNrbxKGL3s/8en0xjCkI9iMZLDEmaaeNb5MKQQHj7CjwIhzo6xG7kFfNbHJV + A7oh8nLE4McwNxkHJB46snWolsPR10PGms4BHbkKlyZauV8jJN97k+NEgKvSk6fUmtqygN+QJ5nW + 69Rf0aqC14qC5HRSDJHu7kNy6vwlaiW4JiWl7nNrRcPxwKnjdCqRZDp0scTR2mUKNZ3oIikLSW9v + CNv4sIUcAZzZ/3i4XkRzwwiiGCR8UWVu221x3bglhib0kdlQ1GZn5tXt6HKjS3kGDp1rIx7Naxq7 + 0JLsKZ7G7SIzhClEKAVzYtAVm03d7Zrq7AbEjtsVtrAlS48/GpLrNRwCjltECuWTLGjZaQUja23B + WJcimiBSqSfPUhv58pgSQ1KCcXkzlzssv6HKfCWtA5B2gSlss5J2zOZc8QrUisgu0iVHj9ZotqXq + +dWqSp1kgds66V4oRQejTiaiY3F+h1Q1lfJsuw1U6jSitSag1mac5tdGlRveDu3L2zQqplrKVtCa + 9nlnLX1iyyMbZ9k4+dTupXTlLI5u5uGtjge5SRupax2cTYKr1ZJ1FROp/TNRky1ksNo7FqYjib0b + o+Ykb/g4rzYhjZYuw1zo5QkMauzH01LoEGwQ9PVlzwh1AG9ds4uFQ8GYBVsmS62gJ2PDO4N+Yw0/ + WbFEcrz5jiqLNrVSVxmQZdXWL+awlG7TDrILWrqByZ8EUQ43XLB3uP2Z2zq9RIlILpFFx1yXkkwZ + IOcQYUOcNaCpc+sKF8uKC9AHOZ4yokIjskgh03X+oo376UYoq/kLuQ6GvhtSPbjCCqHYzGYYH3Fy + ahj7tKm41OBge+RS6pCs7BpQHktAuThe2S3Ll+eDiiEVcVMkhRtkfJms9XZz2U4cEmG+r+3wCwcV + dp231jIEfkac5CkYpagXLS/kJ2x14t0uaLKUZtdkeq17FzF3DR8OaUaNrLFBMHzNq5Cm2arLqhJo + lneiVBO1nfxiVXwPVZFSCK0Z40Vsz99qOExRcA2otRI0VtkfW2gau6lbaTS1sUdePf7ng1a0XZ67 + zZ2E/ffvh+z777/iNaH/77wG+PjCv/MalgnDmc9QIYr7LoszP/OasCse/O3Bxj458S/89AG6TdTl + M9954N/+ek3B+Nfrn3/NSqDC/nr9/rHzbtR5+vt4PMcpm6dKFjNyW2ieeNCubhXkwodG4eYPvwLP + ycoy7arXZw7m/78fbG9+a86WQPaVQ8409EmUq5mDz8QM/ILLzlAfl13rvNH0p2fvPLJq5lDhbNK/ + AmeO6V+xBtzZ3pOFvuLE9UlOPxjiO0kHYVg2D1p4jaPrP0F+KvDd/Dtrb90QwHH26W47jMEXjt6C + po/nqGD8xvpDt8vg6z/DRNmAL+MCBHl1Z8rdQ479kwAIBvjh3Oxa7n6sP/XEY9/n1nvtQeOVbfxI + 52sOgrjLP8aNZyGu5ezfHXY7WL6+A+3PvPzH1vsofgBav4mrt9Tu5gZ46aoX96V3sw68eOPLXJaX + uHiBV/BSddOUgZfAha7ntuCP/9dD79C9e/K5fE37KfBnfeeEzdn9Kp+RZyN8Fn0KYR5/7gX/BH5/ + f/7++4f5+4QVNyB4T8Yn4+/rvz8pvB3wz2z5CXGDIL4nyM3Uzz4/av/bDyc//HuMpvf78cNFgmU1 + pyeaI/fuBtB3YfW4MyzzFDRzld5Grtcgbl0ve5tVu9aNwEdDxcWXgW+B4r//DHwaI799vCXmWxV8 + aKJfeu/HQRLH6F8hvzL8fiU/tDGW+mIdltDNPmASY9/7umu/3sG5b9x7t91P+P7b9/8Bf9t5rz8Q + AAA= + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f752719988393b1-ORD + content-encoding: + - gzip + content-type: + - application/json + date: + - Wed, 06 May 2026 04:13:35 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '4711' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=ADJyoHaKUvEeC3nK2_sSJ7dr9CEJ2NNMqc9USLXclT4-1778040810.495933-1.0.1.1-uG8A.s4fFg.igFwTcj1WtTLUqjC4PfZvnuYaIcoE7CBZKb_G.0rMV93DAq7s2AeJruAKwYTEjGzqEdIZMlC3NnwS6wHlmWG9sXbbAroBBCjQg2JoSGoYK1dMCFoUIdsl; + HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 06 May 2026 + 04:43:35 GMT + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '40000000' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_c6ffb3ca01ef49e7b02c5b09f6dacbd1 + status: + code: 200 + message: OK +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"Solve + this puzzle: call db_lookup(''start''), then follow each instruction step by + step. Each lookup tells you the next key to use. Compute each step in your head. + State only the final integer."},{"type":"reasoning","id":"rs_0429c1fcf5cbfa350169fabfe62a9c8197914677ea9a2424a0","encrypted_content":"gAAAAABp-r_nDEFD_pLd6MEzoOCG8OKekq-Ev8vnbPTkSie4RovCRsX1uArYsm0yKKyAAEYh8tRzZVKUfeKd6HTYEqGrwUeI9Nd7K14uFr0VC8_AEFOX5_RmizhJInjfMLB5xr26SgIbSkBYpELGYzo4H-nlAJVwn_Tgni-Ka5vNjJZsh4-w5wUXQ3NaK4j0bXJYDwtKFlQ5lLzPa4KgzkWJk60HxK847Z5ZVk7WsH9SKDDPSLEsctmXxY5quJk9XUSGN5kR0TPKGscIxTXtzDPHcudCiAjdRGv0g_TIJlIUQ6ofDXWQl8bdpYoqQIbEy3Khqh3ksKoSG71n0UsUw-6d0IeBMQm04VcGiCUhDHaKa3EhFxh5MNgmuVEHDSp4ByweVwK7G4GniKd8NvkauFyROQ2Omqnj_6MKaWccIaQVm5Yw9WU0_PIPFDOKyse7cms2tlyGpFxeDgTgSARsxLaZEZMIWciZT1DazA9LrrD13aCD4ePHWCRJbpbkDH7FM3T2TrT16-G0kCip_OXm9FZZxJvAgUXMbnVEs1SugWBC2nTCfYrLRwXjyzZk35ekzv6LIy7Zigh0t-_RTOWrBkNxA-7jvgCP_YF51cG1no4hTFEHhhcuwYSmMSMMAkcWi0Dh-FXby7k9tjUGMKTjs1RN9NI8tc2lSwmAEdhZdk_JpEnVlfpDEsjRFRIE6RfghF-SRn2gFAUfRczeHKplmtWM0eDKiaK6XKHTPQats0mz1B16alRJOEeFbwMPQA1Myz7atKb8Z5I_IrlGBh1Ho9buaY_RFfCpGemPcO9eRrGoZ7QLwSQH6pw1MrDusZ_pt4vKYC3qDu3rEZ_6OHwFYnRPVj_Qu4bt9f5arKPTKAp2hmEsYKCDvaWeBp2lgEOb66sv34qH6qs-ooCAOG7rmtjSLCVu8F0R11NiFciwvmlmECOwT9d6emgKjn9T4_2CBaUmo9rdEiNfq-cmhMG7a26jGazsIl9Iz-prux7hV8871iYlNDhn_Mr86kkCmknKL53tGb-29XcK-jTUnlLGrHmhnBl06Cf0f-cE7sT6D2tjacDeh9P6gtu0enBfPbuJH75oA240HiKU7gRBd-kYnw6hvhCMKUkjb88Uj51OFOxB0vDLnF4_6y8rswyWoPVeeuRMWHJto_ersRSiuWnzVjJAo4dK9MLfBKRhIth6WDBu6ReL2575QfzUYEFTGXmGPX33OVw0-mgm","summary":[]},{"type":"function_call","call_id":"call_I670mAzR6AYszdoqaI96qg0k","name":"db_lookup","arguments":"{\"key\": + \"start\"}"},{"type":"function_call_output","call_id":"call_I670mAzR6AYszdoqaI96qg0k","output":"Begin + with the value 7."},{"type":"reasoning","id":"rs_0429c1fcf5cbfa350169fabfe83488819788888aaafb2ab1fd","encrypted_content":"gAAAAABp-r_qaKH8G-A088KPm_AKkVfkzrE_w9jGidMqLh3fXoaw9IbtAS9DwhU_OCnhgjF9tD3K66xmrV4gVvDX5mfjjsF4A8rr9sdYPtMw1TmcwMntBVKvJskKjpjZe6s5dswnzCzuUzuG6UOKpRpoZpnmCIew5Sm-ZoxY0PjtXYj7_YdzkzXmb8M1nbFyymgIloaDarelYr71F-wysy_BIxPAfFulc7CeT_jGY6qTj-XaXEjNevpAkRxQs3qDFsf1vJJJSMRUu5vHFO-kmQUvC1C7nNSkK-AQxF4ghx2I_cGJNSHusnjGq5Ia397H_0CBOQjTd1HJIu3UWG8guMSjH9m2elLMFUpfExvbR4iK-l-ewCcWpIyfWxdLWrD3IWqmQ5Id8nEIJFK78mj4VmLPw8JOPkArncScgXqTK6uzkzEX2HtsNECxFR5w1eVZS1yixE6OSj4V5z-DDlxxx0ToibA1J9qcs5QKR-0ULx_LK_Pgf9AeK8_Z2FqHNnFh5nKVWdyIYXxTpurpF1qEnKBCSCwaQ7XzUJlJ-n1UFgZt0URFbsbqY3XshC4urrvqswPq1PLBqhQcZxPbsXqMKzFuwT6J6CEl2GrUoCjHbVDqIh1j6lc5BDYi7aOAYn7bEwQI_thfmQbi7EraVZMmgbEnvYkxvzeP78ASC4YiYwgIzGuUQKzwqRKKbxpe6oeVE79FCeXhEbKT2Z0kcBZSUomxfSCiLtNMC0Ni9WFfUOu94lRtTPdPH8pVu-QBFezi8GfxOXHNqNuFSVI_VQtenIoP4ia1eYxbI2hpwAlk3u8dUSCvJySdLLw9dPWNQxco51zDamyQVKenbnEbj4ZqZRKl-2t_neG0AcX5qliCqDA4mqmL74yirrmYt69kIFQ6SGCP-86km3amgAInOsOBVSQK--HaOvBUfTc-CWLOONqfj2aXUiMCKvSMvo9-6Fug4CsIXdcgxqOQYGfc5Opce8mTUdrSjmhb1WiMO6lk0LSrHq0IUsIh8Dn9X5bhXshn8dC5wl7Ya7Bofyg2EBGTo4Sc7i-nf2BfFX7kqTmOURGa4iZe8hNpuoytgVDlbjylf0NyU-GE0gwubFgmb6Dqn7UdJedE_i4DKIctupippecmwpgO9V1nilTlYoNYjrGryjTRZ6AEj8oaeR2R3G41wUpMHv6uUyYTIKl1l2cAd6cG0g5A8ECg9bQfIUcAAcQN3EdfoIoOXOEqCM-mCEjjP9wQcx4ztNg0_o_TT6CqZgPKDzzW85UgVDgtWGhU464SPEUoYxJrCpWixqQ_fTANdF4snP3hRG47yauIenFdO31zuafOJrcqEDycKflDHvA5YUOiK1iOoDK4abQhZBePmpWyOFf8lufsULGoetqiLG4VM5hdijIqkIOWidiKSBMxX8X75bAeT5uS3nCq6nS0jRLTKJ7LL9qH60med-9GfzMxKXnMHi0yewgVmQOgwir4rs8o2utjshbrOeDz4-Z-4pCskwZqdEqY0Q==","summary":[]},{"type":"function_call","call_id":"call_Uj506iEKjBZWrHPq2C3GDtgr","name":"db_lookup","arguments":"{\"key\": + \"7\"}"},{"type":"function_call_output","call_id":"call_Uj506iEKjBZWrHPq2C3GDtgr","output":"unknown + key"},{"type":"reasoning","id":"rs_0429c1fcf5cbfa350169fabfeb569481978703af8553a073b9","encrypted_content":"gAAAAABp-r_vFGcSWQD083z5Jsq67CmK99_ZqqnwC8n6j4kVfWhNH5J9pJw5e7uV1QurddjHY1w7BdbV4HrfTkz4SUHio6aFeHMUrbQObDiRwP8kHX2JSY3m3wXaXABn5_qJi85Lj7CD6X73jzqdQpEeGQ-5zL3JsxO0k5UuZb01t5EuzuGzw_0LR1Tli0UJw43rKgCEqarHTus3l63d18pXuK5RMBR-3Z_xI93rKewxrkTaH1DBgxtlxtD5Z4f-d0-RbfG14Svq-QS3wsfg5Xrn85KJHwf8yJ55M752AxBArv_iqsV1YuKZAwyFxT0HiLrDOwI_G_QyYfD-0U-HsVGeESCS-g4jwDPDXst2aJPTOyhtTu0DbpXyU_Ukbrtlws4OOZurl-W7LvvXlWJNG0WWj6mpCcHrWMzubGkQSH87c-GXESHoBdxxReXGomDs4gqnbB7dYpgNRNveYzLnJRQdDYkdYHTJI7gBEVNFBw5wY0zXRhYtivh_G4NuL2FYcTPr8zFsxqIUXyLWJ2-95N0jvS_ap4R0H_l5YY8twKvT4stYA39T2PKt067Cl906VXoatJWjW9yfLjn1fIe2m3Wr4VXhkWUm7v7A5x5Q7UObihtAn-5NhoU9BK1FcMEY_0UmTclUAM-SFVrL3Dtf9GtoYoLH8PUb4Sdph9y0D6baVSNnBpydV18l9XtgruSH5p0t9cexYA3zzTAmMb9ujFxYAb_qWZ_BFSFnJo77lYCoiKWeNeEMl3YoW4tXqmosdpqlSFpHKrax-YGeMD1nagNqQFInSLegcESqI8l-Gyqws4X3VGxW_jPGJgjh_wZAWQTKMiKctcA-AP5JOpJxaPzZghRMGpdI73nOOTV_jQ9OVkKKWtjFeMuduFhWLtiichsWWSq8XwJDUKTRyY_LYBxzPFFxIhxkqQ27b_ndJPLvyDT9ATvN856mxSJYelK0uJjxqj13O5ZWICp0pdUGiELg00Q15UIBqllDQv71_Ltnk_oSEprXRlbifFswF8TNVFbvHh5ZF_ajeXNbSgMfs_qSt9HuZD2tybsSsYHqmitpar0udcgNKvyiPKPXab3kwiPYIj_7nvclcqCZUUzddMy5dA18jszEcY5N46eUV_NtZGO7hQ1Z5hSWrmPSeTUHFKg82QfmW8_ejb0y9qfPoxQcTf8XzVd7gaNDkjSoMC-5Fvbz__geDpkRbNqSPs9eFE5RK7TqkcB7PN2qNN-m7jnxgTaMf5zucA0Xj2SJjoTcDCNS22d_qikzo-jzOuiJD7SZzfST4ugJlf5bMUGIiOItD4e_jszixhZHWE8GHHdjFnplasuI2TUwjUSGvgWr66ELWmpwQaDya6bU2t_hrHQWhkiatXJYzizyI3lUGzf-6N1_ieunEEquLrpVad4LDLnYGI5obFQUJabrD-eQI-JfKRKGSlRBtgXCXtiTJg744kJv5VJsgcKyk4xjoeyAwWAL1mw0NKt0XOeJLdWGIlo7_WrVpFNtSngYnkAD7yXyWsoPbcpBpk_j32YT5aZ6NTeyT5jH_XDcu-pq6kF9sIdP6Ry0qSG0XEyz0zXpmUbL0vowzyNWPoKXdsWYVLXAc4IF-E_Ar_mzsMJkB_3Qalb2sT2dmW7UkaXO_Ej2PpA9aNW-qcV4q5st81qbQnW_JHvai0WzKEFimpEf70oa88Z7ARexPicizAGu-1xGTCZM8GqdtwurLiGOn8Wn9ojASn7zUEF_xTw9UcjB94jQwc_PKHY6BPBleKKPYTZWxAJwzLt937JwezczGHKfEDdM_DMVDI_vJ6H-mJ5nu8hAJK6UemD-GE4VSeSP_NtGZXXinevdm2k8HN7-KH6-zhwP8yMzw4NBnwCCdxvuxkTdhtp-69lY88c-_m6xyvkrpDkUDtsQDk6OjBYqe6b94tKnQh9I9FoVOP1-p4wNJNDxK2AjCTsEZIzD-g1ccSL2ZDtN9CmsXAfecl4RKzdyJgvHXbfFz1BRFaudrlk79C5khqva-WLrFfxkl6y9UE-12CFPt779pulpoeSXbR6PW0Y_mZXpFvtPH6N4Sryi3iYObDtxzgdhd6CNdrXovQstWULzuBS76EYyFPQ=","summary":[]},{"type":"function_call","call_id":"call_Vik9mkNli8KsnSbHOShapdmG","name":"db_lookup","arguments":"{\"key\": + \"step1\"}"},{"type":"function_call_output","call_id":"call_Vik9mkNli8KsnSbHOShapdmG","output":"unknown + key"}],"model":"gpt-5.5","reasoning":{"effort":"high"},"store":false,"stream":false,"tools":[{"type":"function","name":"db_lookup","description":"Look + up a value by key in the puzzle database.","parameters":{"properties":{"key":{"type":"string"}},"required":["key"],"type":"object"}}]}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '6407' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: !!binary | + H4sIAAAAAAAA/7VXWY+byhJ+z6+w/BxH7MuR7oONd7BjG29wEqGmafbN7Caa/34BZ2xPltHVlWYe + RqaKqq7+auGrH596vb5j9P/p9ROUxhpGETzETWjSUDcBSWM4w5tAN5HJMDjG4TwLOMqkSBIwDNJx + CsL+59ZFpLsIZq9uojBFNzlMEMiQoYFWh7Msh1GNF7rTpRnI8rS1gVEQ+6h572akA+hZSZSHbVwm + 8FN0Ezu+74RWI/vRPDaCGFxR0tobqEB+FDcPjeLldvCry1+OZjstSpKotQxz3+8EZoIuOQrhVYtR + CPzs2iixL1inc8JXZ5qBMuD46bOlE6ZZksPMaS79LA9ApUV5FueZlkUe+l2ZRZGvQeC/dRdEBvLb + O1lxNqC/0AMCI5gBRg0Isn9/IQHtcc9mt5Mayb8dNDeAHqlN30msybG3xPIkADrCCJo1MAbQ3XGd + k+wao1tqQRqFbQruqgay5Bq3MMMozFDYlYA1bP9G8SDRqmCIHSplk5W25lKYvwLbQBAuziWdNDmj + vXg1on0RwAk6bVezwep4Hg/jhLVGMuPO1dV0PoiTNXvNRTgKx5zmE9VptZueRkeQVIPSkfFJ8HW9 + s5ZRKa+Xu5QUrQ03xwJ5dZlqijG18+mR2YOpcbamJm2iyW5EoulyI2yDFbPEw+RgR5PhCtF4eE2C + mObkmosJcWhuZ6Gqzom5V1nn0UUAZcYsM5uChSmnx42lXtyRGJKqlpbw6gWKEegbejY6wYXnRNHc + kJfqWYsDGRvUvspvKn2yGB/WsrQnYnG8Z3CqRvlFGcCcjmrdY1VKOCx5t9rUB+WcmwUQ1l6lMMyU + 5mCoZdh5W8acJx2yo30aDddidNnALbaXY++g6xeQz3OZzm3PmB3kwtIz63w4qfYQDGOg18l0NKOk + 6VlCcyxW1qeKitNks6cPXLCeHpWcXR4UHJVHiXd1e5f6Kn1ZDeURWU0p5utYwdwolFYckgZT+QLx + C1Geh5qxGe1ktNZ4MjxuvWNQm2LuKIEYFVitnA6B6IhSgfnzen0dOrCYhYUzPJMGPpsnTq5FohCv + dbkSJNbRgOBV+m6QoEKZzpOgWFUlT9hcAt1FMtsdancoT7jscNgPHaygLz7NHufBaO4UFEXklOib + 9jkWJ/MQSu5xuCk346OtZzhb4yJuX/e26ij78AICT+FOpDjYLCR5qs2AbJJHfOCWy9FmNxLgboFd + pXx+srV9GlkupSNF59fFhrC8oqykpWdNdqY0XM3MAbnFF6sa5ppURO5lRwrEAZKSRM9qM9Uoa2Kp + B9/kLtXBsOcInKpgyw3m/rYQ9v7MHawLd5IzYYIUcgJlLJZ0Fk0FPjB4b7rNFJUe1y7ciOaZZvF0 + eD3EWlAHy8lkKm3nllidwvJUjq76/CCpZooBIO7H2KR2YP7VqFUPKSZeHQTXdD3JH9i7VVktzDW1 + UIxtUMbCUZgGPlosBmKd5Bk/3qmhBxxv5wzT83aO02pQUu5xcXHxGb8/sV45qqvYTN1MbSbC5Dw4 + uXt/d6UENr5iuzGbqNnpxEjjNVAxvlj4yn8eYyHNgwAk7RD993sne/n8p7lkwr/OJROnWZ5r5xJP + cibOAQ4ShgkgwH+fS2YedvO3m6ZPQfzl+9IpQWLlQTOvOv2Pb30PXb/1//nW15HlhN/6L483W6fa + Ld7u50k/lWJJLZnJecvKIU+aVpCriHxYhCDo4jJ0zY8iL4/7/z8GALYY6BgweArAVtx8PumPxaAA + fo7exQBlpJ2uIe7ErmDmo6sfDMuLN7l+DAY61WEAyLYMaBLqBKnDD8Ygs1Gvw6HHvovEkkrijYxt + JzUlLIz0OBMn4ozxPqga9K4aeJpjSZMmWd6EkGbMj0Vi1HZEr3Qyu/cEypd3UbEMAioKg4veQpCt + EXNZG6wqMMzHoAK7+uChTnK0qeuESeE8gX0sKo1RkhHvghAuQnEYGOpXXYkvkPy6I09CA8n/UBrN + /+8drYtB0nhC/luy2PDNGyOOG7LdMDD0B9LaqAonylPtlY/fIrsTxjhp7po1LqGNtOZSf9UlqKV1 + N7rZJyj7xkIfVPDOxhsmGSUd/7Mdy/55yacvQev+Ts9TYKLs2sTU+jYd9IaMpygpnOZWmfNK702Q + +1n/59YQJejNXpChIG4pcd7J8Z8AZKjKHsE1oQXg8fxUFN17z7XXL1CiR6nTwdkPkOHkwWOvuCXC + jpr4WjXIs6h/V6S/E/Bfa++RfAOlMHHiV2ilpgB6edwDPztMv/aatPSa1mu7Ls7r2kc9A2RAByn6 + 8l4N3VVt9QRN+pL06eK3/DaANei+lTeaWyE8i56u0Ow5bcKflC/33y+fH+7bVcpJkHEH48n5/fn7 + k8HrAT+XyCcNMAynBQj4m+eYu9x/+uXkLr5uB23745dGyqK4gcdqbq63DrC7MO56hudugqTJ0utu + 1TecFOj+61Kap8BCj4JywjebHU0Sn39XPO2LPx5Toukq42GJvam9XzdGHOf/pPmT43tLPlmzb5xn + UQb8h5ah8XtZ5+nbFmzKBrTF1h7w8unlvzbc0z8nEAAA + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f752737fd3c0044-ORD + content-encoding: + - gzip + content-type: + - application/json + date: + - Wed, 06 May 2026 04:13:37 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '2162' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=_EXrqe7IjNWn6uSESYNRV6be_iYv5vfdiSH8V3vdNgs-1778040815.3529754-1.0.1.1-qv4Kk81buc06G3o0AUYL254nHY_tKoM.r4ToKt1atzCBnU7vDOrMSgZt87H9a__SsX0twv_Dqbwnv35sedBqkSVJ5tcTI3rPQI4rOXZ2o7bFUr43uDG7zWB_uR4Rv0xw; + HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 06 May 2026 + 04:43:37 GMT + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '40000000' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_83b594dde5454daa8a186b3a26e8df84 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_openai_responses/test_responses_round_trips_encrypted_reasoning.yaml b/tests/cassettes/test_openai_responses/test_responses_round_trips_encrypted_reasoning.yaml new file mode 100644 index 0000000..023c034 --- /dev/null +++ b/tests/cassettes/test_openai_responses/test_responses_round_trips_encrypted_reasoning.yaml @@ -0,0 +1,356 @@ +interactions: +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"Pick + a clever country name, look up its population, then check whether it can have + dragons. Be brief."}],"model":"gpt-5.5","reasoning":{"effort":"high"},"store":false,"stream":false,"tools":[{"type":"function","name":"lookup_population","description":"Returns + the current population of the specified fictional country.","parameters":{"properties":{"country":{"type":"string"}},"required":["country"],"type":"object"}},{"type":"function","name":"can_have_dragons","description":"Returns + True if the specified population can have dragons.","parameters":{"properties":{"population":{"type":"integer"}},"required":["population"],"type":"object"}}]}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '722' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: !!binary | + H4sIAAAAAAAA/71XWZOiyhJ+n1/R4fNwApD1vLmhICIqrmcmiAIKRLZiKRUm+r9fwG61e3oi7hJx + fTAks3Kp/NL8kl/fXl46vtP5+6WTwRyZpMsIpGjRPMORXZZySIoTXWDRFO9YXUagRM4SBbLbpSyW + Z1yGtbnO98ZFYp2gXby7SeIc3uR2BkEBHRM0Oorna1ue5qlWlxegwHljYycRCmF97mZkATvwsgTH + TV4uCHN4E/th6MdeLftVP9YCBEqYNfYOPMMwQfVDrXi9BX53+Sk002phliWNZYzDsBW4GUwxjO3S + RDAGYVHWSvIvstX58bsz04EF8MP82dKP8yLDduHXl36WR+BqJrhAuDCLJIC/K4skCU0bhB/dRYkD + w+ZOHioI9i+WoEmaI0iGoLud+4EMNOGezW6Rask/bWluBXpAm/8ZWEHkHbsBVuRIKHAC6Vo8DQTh + hkXrpCgRvEEL8iRuILir6pJlJWrKbCdxAeO2Bbxe8+kjIpOBBrltEBuzWB8YW06R9+t5ni0sbX02 + gpEZbcg1QKP1hdFJlkulrrgBVzQ4GWMJDa04PRwZbFu5gayRvL86Z4IgWG8ZBZUHT0LXYUdld1EE + qrQnSo4ieR0rZ6wOh/mih1fq3jP8rjII0zxBWlAxhaAMVuVAFzyDG9mZWAWrfDTTJolSTg8CvZwY + ispr5arAqevK/mouKT6xEbeStcgnV2QtSWuUrOH4pKygI3CcaOpbKVIFo/R6R0nDVzI7L3ejSCMT + cb2wgZkJfkokRmH39+RMt/gySgvqgCfunqeWWo514miJs3nXlLtyts1ExdDYfLmQVYK9+Ny+HCn0 + DBnVGYPteoO7YD4OxghWrBsEG2dJc0FxusrustiQZ6WSNgItCodetNEjOln23TzzTAvtmaWo2Zy6 + mSJigLfm8mrsaLHf3RCTqZoMp9HZj7dLyByG4s53Np7pLqI9POdaJqiL7kTDO1kdokWyE2Lblvub + FR6a9my4WJUWkwuUvhghJw4de7qiMNCwtpH2/aB/1aeYy6mxxpKJdLog3fLMgKTigb5zLrsZmSpL + ja+jKMZQVK14zc7OkDxq+lwxB2Sw5emUkg/eKZ76cRisnD2pDFfkvqf0qjklnibqfOcUVUAX/Q07 + 73Nj06MDow8inouW2mYwG1aD3Yq4CvZoA9lpNOyFmryohHBb9WVH2gUkWfbpmW1soozmablUgqWm + pjTAWJLmYbDexfR6xubdPvLn+X4Es52Ap5fJsOfLe9mE3rXq2ds5F7NuNZISdLACZ76YHqzrwoWX + /gEcJ0MmWJ8YYcLBKEkm5algRHarbk0C9wqXmCribNdjexsc9qsNbywoiSt3l9UIYEtaoQpNQaFH + PdHr9tM4To18wkCR7LOKTKuXPOPZ9TCaKWhywaPpXBHOVSz6UARzW3Tko3GaHt3JKPFywvL7Vjo1 + lHhZDbB2PBxT54DFrehmg+mxIhmtOqPo4DuiOMp54cRN5olzFgZ6PAvKmDXjA9EbTqTzAfqAzRNp + oxKMsL+sp0XFBCk3JSR5jC7d5YUcRr1uFYKRM+aOy3kGrEDXS0/qn2bEXPZFchGd8DUsNoeejPJq + e1pn6Ulxk5gliMw38DwS0xOp48swJR01NQp/tIl5wmGAkgnmVFxkzhipO3xSK10NUDaGe2kMpGPB + cpQ2WZbcmgtjIXaP3bMvkAfE296unpj97nAUT2RtRB/Y6JypYhBg+xJoFwsqRDqX9cE+jHS+2oVD + 0Ys4czO7hhREU1Zbn8qBHYmz8XhGq7RCQdeezi6LvqtpHEJHTE3plXdxPVJCepTq3o4I+2eKTmfO + cjCbBnNjT/bUuSyfWE0Xd9UBu+yWG85PScmp14Ww6suDbjq8rIEgY1afCqMg8LRgi6Gh6BUy9scT + WKZ4b/aFx6jNcRSBrCGmf362stfvX8161/7zrAddxiVbEqcYm7YcngI0bTsc+H3WuzhuOa1lqKck + /sDZrRJkHo5qDmj1v37UR3BcZOWPzt8/OnrN5kkGfnReH+cb1+Yt6/YnLvnC1TZJIGu8dpIkLuDn + hVo+hY9B1GYXJkmAkYkShMMbF94qUn//bPkQgaz2CMOPLFsT9W2VQPWWUlMX/ILta9XZT3Buvi8y + twzvTIuy+uJF7dI+QjOA5R91GWz48MbTHZo53uj7waH3NQa6bpK1xHn0vePbZZ/gbtzf95ocuLAo + 65wa364PP2wxOczOfn2rwn/fi1yAw6Lztm4lGfywUBUwQs0ugVs59VaAAl6LR3J1ahF4PD91SHvu + uRE7Z5hZSe635exE0PFx9FjIbkAckzq/Rg1wkXTuivz3zeVzIz6awIG5nfnovbRLWF8gzl+KI3yx + cZbVhXl5NMZL4raaHEG7qZfz4vqtPxC+vPXnX/9Of92PNJ0V1dBm+VNRbtjXxawr/1HerqJtmE/i + pyvWC2TTEE/K1/vv1++PEM2O6mfQuRfrU4C77OeT0XuQtw39SQMcx79VQn/Ove2Pb5+itzm2C37z + H/py/PzHgBm1pxf/MzxP0NkgfjmCM3xxMuDVf8UvgKqPmM0R8+3I/4LTE9p/hMqvl1zv9p7x32H1 + eWT9/+C6z8YiQWaYeHUhrMYBeReidgyKwk2Q1Ti+V6Pj+DmwwvcXNJwDDz5mhB9/eMsRme+/y59e + nX495n89J52HIflhmnx+eRLYrxRf+b3P2Icx1/3gu0gKED60FC/e5xTOP87UuoeAAwrQBHj99vov + v/3TYzEPAAA= + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f74d0b45806549d-ORD + content-encoding: + - gzip + content-type: + - application/json + date: + - Wed, 06 May 2026 03:14:34 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '2715' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=JaAjlN.SsTHssMfEA38GX9HitAbY0eJ2RFPOYXHE.dM-1778037271.7367537-1.0.1.1-ITxNEO9g5Dnpe0xysdsQx6Pb4UfPCLUH47.8gv2jcppI9YSzDXqxNV1wVH1S0DOjPhkjvEoameT18PZ3jubWSiQeRsWVcFTBf8rKD.riIQ5F_txQnQ76olVhtIu0ZumI; + HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 06 May 2026 + 03:44:34 GMT + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '40000000' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_83a358e390da43f0af03fb3186d6e121 + status: + code: 200 + message: OK +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"Pick + a clever country name, look up its population, then check whether it can have + dragons. Be brief."},{"type":"reasoning","id":"rs_0f4809b27460351d0169fab21897dc8196960e8680fb72a88d","encrypted_content":"gAAAAABp-rIaNe6WknTMnPCTW6JIYUOsrQbNUvTkE_mV0UapEUw4P056qF39VaxpCjTGFpDbnqZh4ucbsTpbEIYxdv---5gRmkzgej83d5Ey3QtkLFY-y6107PuJvuLDDsQAuSLYgTi3JClqsopNkz4t8JCSyCP8gT6Ecr9zkSsEMNHoJyKZ82RHTJL7NyStuqffIiSOFJi-V9WFbQsHxpbR0bEoUeGjJSed8669_PWFmL8TygAhFNux0rvRXEmN0o9UQca_r8iq-oTtcBY0MPb7ymqt1ZuHfY71RNsuP-hb9MO3_I3IrWr9JTN5sRQIL-5wi6YyEJ2MpTzvuaWUVu3aOGkGpez5fkkVdR26ktjxIfRtV0vJzFV8298ZAmVPm2oRBfsrg_bpY4R9Nc6LVKp-CuW_RxTX29B3V-HKLoDKmvinWRe4ZD9XidVg_fQmYevsNr8LQ3HNuXILDpQoX8nccIBVSuD_cMDQSyb4s81PQEpdnldcKS1uaNuNVFYBkBxPKu6s1GN50oFjwpPbg_k01nCPXdwXM0qJRN7evsJTD9LbnU5Mve0hNPOJ_C0kW72q1IZgjnKinlkSdY0JDS0YAJAzO19jHLOXdtzk2tBV5OB6G_g2kTBam76mRNVCMDzCXS-x8cEVe5KmDAlNIQz8lWzBIdFXk00yB2McTVmr272IyJkRNLq2auuFFOlkUXn2UM5s3BpiOsYEerX8uKwHDAiIYI_egxzAcWO6n5fzEFopZbkdOQKZbxQfewBZahHD4kUj48H6emooHyjt495WLW_-uAtf-KJ9MXA5AVulBzV7TQ1F6yXwSEaubFSpzpKatPmA9g3BqnnqTsH4e90B5JI2Lwsr75UDmMJpHwuEKOJ8vzn9ie9aOc9dIhTjKhfHEogs-biBbqKTJnRzCuNhZhqdZu9W9frCKhz04NzvpmZid99Es78j6HOodv8CPnMkyn5_nZ-ADHFvZeia5soFVL-48YwUKtz4kq6K-FIGpw3Rw0DmA3zlaEdG6hROrabkPPygFBjM-OIi90QmjuxltVZAIpszWjUrqjJfon5--riTuOm9qj0PuwDq0dLqTtiEVn7-d4aJr8_K9QrdGpLXujLzPLkprGeYFGaFht561NHRy6U6ln8nfh3vi80Zp7cgX4-2B3DEnHINE2Z5mvrL9kkucwkNwbeJ-qOIPCYlmP7zXlD9gm6_VMxl1epK5NUjyCcm9MGGM2L2J1efcKMwQBfNN6pphu1K2Sgwfg0FpPmqPgX-lBv12qMdRCMKkOTY0ALOIIj5NP9XzZuf5W6DOjoy6LxQ8SBIC3qDwUa8Iu5PK8EkkgNkWueTJPzpTYhjaRquY_B8","summary":[]},{"type":"function_call","call_id":"call_uy7tfNVokIN7NjFF6k7OtLyl","name":"lookup_population","arguments":"{\"country\": + \"Pundora\"}"},{"type":"function_call_output","call_id":"call_uy7tfNVokIN7NjFF6k7OtLyl","output":"123124"}],"model":"gpt-5.5","reasoning":{"effort":"high"},"store":false,"stream":false,"tools":[{"type":"function","name":"lookup_population","description":"Returns + the current population of the specified fictional country.","parameters":{"properties":{"country":{"type":"string"}},"required":["country"],"type":"object"}},{"type":"function","name":"can_have_dragons","description":"Returns + True if the specified population can have dragons.","parameters":{"properties":{"population":{"type":"integer"}},"required":["population"],"type":"object"}}]}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '2423' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: !!binary | + H4sIAAAAAAAA/71VS4/jNgy+z68wfJ4Z2M7Te+t5L9ttgW7bXRiyRTuayJKqR7rBIP+9lBS/MhOg + 2AK9BDE/8SPFj6ReH5IkZTT9kKQajKqydr3PyrrYrbfZapPTLN+WLamLnLTbItvn5bZe53S/o7SE + ksKmLdNHTyHrF2jsQCOFgWhvNBALtCIey3e7fbbaIXnAjCXWGe/TyF5xwHPRqSbNsdPSCZ9XS7iB + aGacM9Gh7RU/0aDIGbT3p3ACLhV+IHCJgQfKm9CbgILW0nsKx3kwtBr+ciCac6VAEG7PCGbPWcCY + GMgqCpYwbuaeTBirXWMZXnpu78n3SjqrnK2sPMJb0ErJq4bwJV0vKXB/p07Zp83z5qnIiu1Ttn4q + Vul4QBMfbu4WI6Hlz1CaWKBR2ra5L2zdwH4fhM3qVVm3BdB8vVuvorCBxJ4VBBonwj1D1hN8T8cA + Et25HoQN+OvXVEnleEj/a/ohL1Z5sb5Mpz1xFXMOf1/+/n1/5Py3n4yQv3yxL1/++Hnz8fOWTR6C + 9BCPi+pATiiRJp2XIhy44O+3UCBFNBICX5YdlYu9pbBtUX54R36ETkw6Uw2dHRMcS6803toiZXOA + 6gjnu5jGyoircGmxPkQ9cUCMFIu+hraVOgzTgXWH611T4/qe6IF+bHRDWrBnzMlztwwWbW1Anxje + yrJhUFriuE2v8yc1LCbMQq98c7lgz68FsPDdTslhaj2ZvmftEc7Fsl9TPoGupWGhnGkPlLl+mtAo + xEFifh4mzsp0BMzbVr7twqkHKJhGMzWU9jPgBYRJ7AGSxmmNhUmmtktkGxCjoPH1oknLAh/hSYM7 + x+rz89v24lIenaommumI76wepdVmVpSoPRYTK7+0h90UwtyYZ1fEjeIbYgZexv+XxymEX1pMAx2L + dRNgtH2bOQ1Brit7hhBKWazEp3nuoT8ebqKHHMPG9zM0V/3HBfsVmRJ2K89MOhzyxA95ch3y53+x + B/6DTjO170rFhIUuPjw/ptUsyP8s17gbrVQVlx0WovYE2WhUYQ2W+2jQqONQjZQyQ2o+vNjOkA6m + HcHE4tnLy+LxLTB7TF+n/Y+Lkk6e2WKd3D6nRf4e8B7vuGTvUVtpCZ8zr8Y95cxyp2IPEUos8fyX + h8s/3J7RqkIJAAA= + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f74d0c81c4d2b4b-ORD + content-encoding: + - gzip + content-type: + - application/json + date: + - Wed, 06 May 2026 03:14:36 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '1077' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=Ff7ZRSpcLAJPDQ.asF9RF4bqWtg97VpC0E51yPz81.U-1778037274.8993971-1.0.1.1-lFBHWH1sK41g3Of_MJ292u9sow8m9djH6vBQSla3pNCvWDtdVwVDdW.ko4QJQaLNA6RhV21md8copjjSWmsVnZJzyKsQgS.IPimoDzj3d9Lt6eQuV2eXHZenOh25fKxo; + HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 06 May 2026 + 03:44:36 GMT + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '40000000' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_477366fddd32402daba00618a4c16efd + status: + code: 200 + message: OK +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"Pick + a clever country name, look up its population, then check whether it can have + dragons. Be brief."},{"type":"reasoning","id":"rs_0f4809b27460351d0169fab21897dc8196960e8680fb72a88d","encrypted_content":"gAAAAABp-rIaNe6WknTMnPCTW6JIYUOsrQbNUvTkE_mV0UapEUw4P056qF39VaxpCjTGFpDbnqZh4ucbsTpbEIYxdv---5gRmkzgej83d5Ey3QtkLFY-y6107PuJvuLDDsQAuSLYgTi3JClqsopNkz4t8JCSyCP8gT6Ecr9zkSsEMNHoJyKZ82RHTJL7NyStuqffIiSOFJi-V9WFbQsHxpbR0bEoUeGjJSed8669_PWFmL8TygAhFNux0rvRXEmN0o9UQca_r8iq-oTtcBY0MPb7ymqt1ZuHfY71RNsuP-hb9MO3_I3IrWr9JTN5sRQIL-5wi6YyEJ2MpTzvuaWUVu3aOGkGpez5fkkVdR26ktjxIfRtV0vJzFV8298ZAmVPm2oRBfsrg_bpY4R9Nc6LVKp-CuW_RxTX29B3V-HKLoDKmvinWRe4ZD9XidVg_fQmYevsNr8LQ3HNuXILDpQoX8nccIBVSuD_cMDQSyb4s81PQEpdnldcKS1uaNuNVFYBkBxPKu6s1GN50oFjwpPbg_k01nCPXdwXM0qJRN7evsJTD9LbnU5Mve0hNPOJ_C0kW72q1IZgjnKinlkSdY0JDS0YAJAzO19jHLOXdtzk2tBV5OB6G_g2kTBam76mRNVCMDzCXS-x8cEVe5KmDAlNIQz8lWzBIdFXk00yB2McTVmr272IyJkRNLq2auuFFOlkUXn2UM5s3BpiOsYEerX8uKwHDAiIYI_egxzAcWO6n5fzEFopZbkdOQKZbxQfewBZahHD4kUj48H6emooHyjt495WLW_-uAtf-KJ9MXA5AVulBzV7TQ1F6yXwSEaubFSpzpKatPmA9g3BqnnqTsH4e90B5JI2Lwsr75UDmMJpHwuEKOJ8vzn9ie9aOc9dIhTjKhfHEogs-biBbqKTJnRzCuNhZhqdZu9W9frCKhz04NzvpmZid99Es78j6HOodv8CPnMkyn5_nZ-ADHFvZeia5soFVL-48YwUKtz4kq6K-FIGpw3Rw0DmA3zlaEdG6hROrabkPPygFBjM-OIi90QmjuxltVZAIpszWjUrqjJfon5--riTuOm9qj0PuwDq0dLqTtiEVn7-d4aJr8_K9QrdGpLXujLzPLkprGeYFGaFht561NHRy6U6ln8nfh3vi80Zp7cgX4-2B3DEnHINE2Z5mvrL9kkucwkNwbeJ-qOIPCYlmP7zXlD9gm6_VMxl1epK5NUjyCcm9MGGM2L2J1efcKMwQBfNN6pphu1K2Sgwfg0FpPmqPgX-lBv12qMdRCMKkOTY0ALOIIj5NP9XzZuf5W6DOjoy6LxQ8SBIC3qDwUa8Iu5PK8EkkgNkWueTJPzpTYhjaRquY_B8","summary":[]},{"type":"function_call","call_id":"call_uy7tfNVokIN7NjFF6k7OtLyl","name":"lookup_population","arguments":"{\"country\": + \"Pundora\"}"},{"type":"function_call_output","call_id":"call_uy7tfNVokIN7NjFF6k7OtLyl","output":"123124"},{"type":"function_call","call_id":"call_jwY8kllWAsnoSXtjXZQ5KR6i","name":"can_have_dragons","arguments":"{\"population\": + 123124}"},{"type":"function_call_output","call_id":"call_jwY8kllWAsnoSXtjXZQ5KR6i","output":"true"}],"model":"gpt-5.5","reasoning":{"effort":"high"},"store":false,"stream":false,"tools":[{"type":"function","name":"lookup_population","description":"Returns + the current population of the specified fictional country.","parameters":{"properties":{"country":{"type":"string"}},"required":["country"],"type":"object"}},{"type":"function","name":"can_have_dragons","description":"Returns + True if the specified population can have dragons.","parameters":{"properties":{"population":{"type":"integer"}},"required":["population"],"type":"object"}}]}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '2645' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: !!binary | + H4sIAAAAAAAA/71Wy47bOBC8z1cQOs8MJFm27P2KINhbNhAosWVzhyIZPpwYgf89TcqSKHtmDwmw + N6uLrCa7qpv++URIxln2F8kMWN3kfbXPD21ZV7t8sy1YXuwOPW3LoisZq/bFYXfYtvuS9mULXdHW + fZ49BwrV/gudm2iUtDDGOwPUAWtowIq63uebuqx3EbOOOm/Dnk4NWgCuGze1tHs7GuVlOFdPhYUx + zIXg8oixn/iJAU0vYMJ+BmcQSuMHAtcx8UR5l7qOKBijwk7phYiB3sA3D7K7NBokFe6CYP6aR4zL + iaxh4CgXNt3JpXXGd47jpdP4QH80yjvtXePUGzyCTinRdFSs6QbFQIQ7HbV72b5uX8q83L3k1Uu5 + yeYFhoZ06bYxE0a+xNKMBZqlHezxP5Rl9SYPyrZ1Ve9hAzsGHc1bGvNFFnfREHnAWnqEBfhIwgh2 + SjqQy6HSg61opzLBDzfvjguolMrRqbRfvq5AoY7aqPYdJBIh7yd0kDKUnKgllGilvYhkRPWkKDfP + RVmRf3yZFxWhkpEL2GfCHemoxC1nIMzQI6Z+zWby6+3XnC/TSB4v0XP0TUOl/Y42nGGjRESptRyL + hdV4mngiB3rYoAVArO2Ajho9r7Gd0Jbwji0ROnPlbTN1XBPFni2BxRm0Q8ruBM0bXD7EDASZRkNl + ZXUafYaNa5Vc9Rv0vTKxsid+PN0umVk/DNRM9HMDWtqDu+CZAnfPYdVuFsyZ460cnxq4p16M6qOp + lIFV5zsYdDC9j/HiVoCbzLfD4dEGunwn9orrxrLfjnwG0yrLYznR1Iz7YZkcoxAnheeLynmnshmw + jy02pem9jFNgEZ+B7QzXU2k/A15AWuJOQDpvDBbmzpQBsRq6UC9Geh75qCAdzkJnLq8LtaRDTCqU + evO6WWiWJcFZA0prbFKUUXssJlZ+HY8NG9PchZMr4qQLhkjA69IaSwtmYZhyA2zV+kmCOZb07TIN + xqckQShjfKzEp/Ts0R9Pd9njGeNLFHooVf33BfsbmQi/lyeR7nFiPAiFS5qwpLkt+ROdErU/lIrj + 7D2OD+LvaZUk+Z/lmmejU7pJpnw+B3Ucg4f9GDCo41SNjHFLWzH9k/DxuZpnBJer57gs6+dHIHnk + Z8vEQcmWnflqnNw/82X1HvAe7zxkP6J2+P6JhHlbzHPK2/VMRQ9RRh0N/Nen6y8ML4eS2gkAAA== + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f74d0cfcf8b2b4b-ORD + content-encoding: + - gzip + content-type: + - application/json + date: + - Wed, 06 May 2026 03:14:37 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '1306' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=QSHgROUcoFXh6zyyvGCnOLGXX2c4b7S8nJowB4RJhtI-1778037276.1270735-1.0.1.1-m3I6gMTwPPLlpeHgduEBRKmVIQGDDZcoItTTaVkwIUQXMOTcCG5_fl8MQIwjyawnDJPGxUthuGFfjTpsPX1OSBOXGmAUziyOhJ04qWwyUzM7fMtNEWtTmOgvh4au55D0; + HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 06 May 2026 + 03:44:37 GMT + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '40000000' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_e20848e56750417e9dd0940f7c4fc82e + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_openai_responses/test_responses_tool_use.yaml b/tests/cassettes/test_openai_responses/test_responses_tool_use.yaml new file mode 100644 index 0000000..4cd3af5 --- /dev/null +++ b/tests/cassettes/test_openai_responses/test_responses_tool_use.yaml @@ -0,0 +1,216 @@ +interactions: +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"What + is 1231 * 2331? Use the multiply tool."}],"model":"gpt-5.5","reasoning":{"effort":"low"},"store":false,"stream":false,"tools":[{"type":"function","name":"multiply","description":"Multiply + two numbers.","parameters":{"properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"],"type":"object"}}]}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '400' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: !!binary | + H4sIAAAAAAAA/5VVS0/bQBC+8yuQz4BsB8cO115aaKVKPaGCrLF3HBbW3u0+gAjlv3d2Hb8CVGoO + UXa+nW/n8c3k7eT0NOIsujqNNBpVxlkdJyxjNVaX66TO4mS9aaBKqirDOi6SzbrKcVU0RRrnAJgy + Fp15Clk9Ym0HGtkZ7O21RrDISvBYkudFvMqTfBMwY8E6431q2SqBdK93qqB+2mrpOh9XA8Jgb+ZC + 8G5Ltjc6kkHBDrX3Z/iMQio6ELDvHx4ol08XcUBRa+k9OydEMDQa/zjs6l2psANhdwTGF/1l3g1k + JUMLXJi5J++M1a62nJKe21t4LaWzytnSyid8D1opRVmDWNK1kqHwOW2VPc8usvM0Ttfn8eV5uorG + Cxr8c3O3/iWy/A6l6Qs0trapP29svYY0NBaKJt9keZauWYLNZhOeCyR2pzDQuC7kGaKe4M/6GEDQ + W9diZwP+dhfBXXSVpKvk7C6q6Ge6WiX76bYnLvuYw8/uuv1yk/+6xhU8sttbJb5VX+Ob7ffJo4M2 + xNY6YbkSuygAe/q+D4VRoIkIxbLc1LFeU4rkSm3HD9pO0DOXzpSDovvAxpIrTdlaoqwfsHzC3aeY + pop0h4ZF6eVD30caDCO7hZ6xaaQOQyTkyyHFyLi2BT2wj/o20KDdUUieuuG4ULNB/cwpKcuH+WiA + 6hMdxk5qXAyWxVZ5TblgTw75W3y1U2wUWQvTeaaKcK+v+iHkZ9SVNDxUM2qRcddOg9n34UFSfB4G + Z2U0Aua9go/FN7Weoak1V0NlfxwUcGpfJJWirVCbi38IZUS8RFrqkTaz9PomUlmohku7F/WRYRYm + 7yxu+0U0fPZnc9/q/3xPPmCJ/LLiGtlYrTGuo6fG0/3Me3jusLNnCDDGfTlB/JznHpRychQG6Ujz + sPL9MB1NnZWqFHJLFaw8QTwaVRiwTdEbNPV0WGUR4wYqMfwHOANbnOTHu8UizYqz9/bZdn6bFgpN + IJsc44VQj/dzuvoI+Ih3nN7PqK20ICawSMYBcGY5rCQ9YGCDpvYn+79G2nD3kgcAAA== + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f74ce7299e4cb75-DFW + content-encoding: + - gzip + content-type: + - application/json + date: + - Wed, 06 May 2026 03:13:00 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '1326' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=zXi8aTY7cDmdwne_.I.3Hf.KrkQjzxMBHFbO2WWZqFg-1778037179.2921715-1.0.1.1-l3RZkU_etG9t6aSFPSTnlBiIypr5vSKjMdMV9iEuWrDk59KcVyvnaNk5TDFILQjTF1bFbvNJ5NmInvZIg_tp2BdSmbNPLyyqdw3RXJ9W9MUEfl9QCXSFs4E6vlaRqupr; + HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 06 May 2026 + 03:43:00 GMT + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '40000000' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_e6f3256822534dd38be1cf139234e596 + status: + code: 200 + message: OK +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"What + is 1231 * 2331? Use the multiply tool."},{"type":"function_call","call_id":"call_nJmCK7SJe3ajdYYplIbH0KgL","name":"multiply","arguments":"{\"a\": + 1231, \"b\": 2331}"},{"type":"function_call_output","call_id":"call_nJmCK7SJe3ajdYYplIbH0KgL","output":"2869461"}],"model":"gpt-5.5","reasoning":{"effort":"low"},"store":false,"stream":false,"tools":[{"type":"function","name":"multiply","description":"Multiply + two numbers.","parameters":{"properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"],"type":"object"}}]}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '619' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: !!binary | + H4sIAAAAAAAA/5VVTY/aMBC98ytQjhWskgAhVOpPqNT7qoomyQTcdWzXdthFK/57x3YSEhYOPUFm + PM/z8d74c7FcRqyOvi8jjUYV8b7aplBleZlWh+0miZPs0ECZlBVmeZwnsMkPmMEO4sMW8jrLMFo5 + CFn+wcoOMFKY3l5pBIt1Ac6X7Pd5vNkneex9xoLtjIupZKs40rkQVEL1dtSyEy6vBrjBYGacM3Ek + 2yd9kkHBBbWLr/GMXCr6IMc1XDxA3l2deC9qLV2k6Dj3hkbj3w5FdSkUCuD2Qs74JeTJxABW1GiB + cTONZMJY3VWWUdFTewsfheys6mxh5Rt+dVopeVEBn8O1skbuajoqu9697NZpnGbreLtON9F4QIO7 + bhoWbiLLq29NaNA42tYcn0+2dhY3WdjRSHebJq92+30FYYIexV4Uehw0Bo4Tx7MRemclhUVxS2qa + 2Ax2aBN+2DHaHwAhpIWhta+/Z04uj0rL8oHHAxFukm6S5bdluqGfH8t0lWeH1TZLovHstf83hkfq + BMbn1DCiQQHCvBOrRreW3HvBGEa1U3GLAcdjECU1TRT5fLpEkEBhReogluEDlpHrzGRnikFAhZ/d + OGGqtVWWIKsTFm94eerT6Loe+BGl21OgDenQSDGTDzaN1L5RXL73NUama1vQA/ooJwMN2gul5KAb + hjPxGNRnRkVZNsixgY6HWRJFpMaZji22ylG48/akr78fWp8bZdbC7XtCFn8udL1P+Yy6lIb5bhJF + a9a1tz0Q5nCSlJ8fXGdlNDrMV8EM1zSd8Jq+zb5GU2mmhs7+pAqZ4pelfZfUirZEbV5upwW0QTL9 + sZvHUaSlGWkzKS8MkdpCPZzbnQzuDJM0GUnsGPbeSOqZFsr/i108QIncbmQa65mSfV53V41fEz3e + VB6eiIkH6pq5dgL/Na3dM2VxlwbxSDP/wjgx3anOSlVM1kE8GpUX2CEPBk0zHTZnVDMDJR+enM7v + tZF+TMz29mG7+mqfPAYje7wC61tgPCPq/XOQ7B85HuGO6n0GbWlP8glykowK6MxcrcQ9qMF6Ul0X + 1381ZAUOAggAAA== + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f74ce7c3b71cb75-DFW + content-encoding: + - gzip + content-type: + - application/json + date: + - Wed, 06 May 2026 03:13:02 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '1106' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=yqi2SzRPEQIj_22_ctCcbWfMKVpSPEKe83ZGD3EBaLY-1778037180.8329482-1.0.1.1-enl4RmONGIjXc08ZO75dW7K7g.J5p7W_GNXjJSYbhPlgeJK0.2h4kJfDALyQFDJUpAFXFOhfg.OC3VA.I7QfOELuUPnL1HR2iM7vf7Giaqal.KxFdQJDyFBy5H6GRQcu; + HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 06 May 2026 + 03:43:02 GMT + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '40000000' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_3a3a8ea3224a44ef8a1ccfa281e21c3a + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_openai_responses/test_responses_tool_use_streaming.yaml b/tests/cassettes/test_openai_responses/test_responses_tool_use_streaming.yaml new file mode 100644 index 0000000..c20fa5e --- /dev/null +++ b/tests/cassettes/test_openai_responses/test_responses_tool_use_streaming.yaml @@ -0,0 +1,393 @@ +interactions: +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"What + is 1231 * 2331? Use the multiply tool."}],"model":"gpt-5.5","reasoning":{"effort":"low"},"store":false,"stream":true,"tools":[{"type":"function","name":"multiply","description":"Multiply + two numbers.","parameters":{"properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"],"type":"object"}}]}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '399' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: 'event: response.created + + data: {"type":"response.created","response":{"id":"resp_00d64fa806f333310169fab1be69d081a08f8285661855594c","object":"response","created_at":1778037182,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5-2026-04-23","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"effort":"low","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Multiply + two numbers.","name":"multiply","parameters":{"properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + + event: response.in_progress + + data: {"type":"response.in_progress","response":{"id":"resp_00d64fa806f333310169fab1be69d081a08f8285661855594c","object":"response","created_at":1778037182,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5-2026-04-23","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"effort":"low","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Multiply + two numbers.","name":"multiply","parameters":{"properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + + event: response.output_item.added + + data: {"type":"response.output_item.added","item":{"id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","type":"function_call","status":"in_progress","arguments":"","call_id":"call_sVidsfFJ6zlzRpelrPkTPlpd","name":"multiply"},"output_index":0,"sequence_number":2} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","obfuscation":"ReLAoBL6oNRsXA","output_index":0,"sequence_number":3} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"a","item_id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","obfuscation":"oZA1PK3x65X0w51","output_index":0,"sequence_number":4} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"\":","item_id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","obfuscation":"WFzXP9CAvnsSdE","output_index":0,"sequence_number":5} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"123","item_id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","obfuscation":"3bk9yszS1TEHx","output_index":0,"sequence_number":6} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"1","item_id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","obfuscation":"ckqcfb2NtXm34XT","output_index":0,"sequence_number":7} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":",\"","item_id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","obfuscation":"cspustnrzHoiSV","output_index":0,"sequence_number":8} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"b","item_id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","obfuscation":"HhO4w36skjtIxaU","output_index":0,"sequence_number":9} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"\":","item_id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","obfuscation":"SnDoVlFiXfYxtl","output_index":0,"sequence_number":10} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"233","item_id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","obfuscation":"arziGMMxMM5qo","output_index":0,"sequence_number":11} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"1","item_id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","obfuscation":"obhmaTA5ZilaAQc","output_index":0,"sequence_number":12} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"}","item_id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","obfuscation":"Mezf0KdDroRLDUI","output_index":0,"sequence_number":13} + + + event: response.function_call_arguments.done + + data: {"type":"response.function_call_arguments.done","arguments":"{\"a\":1231,\"b\":2331}","item_id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","output_index":0,"sequence_number":14} + + + event: response.output_item.done + + data: {"type":"response.output_item.done","item":{"id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","type":"function_call","status":"completed","arguments":"{\"a\":1231,\"b\":2331}","call_id":"call_sVidsfFJ6zlzRpelrPkTPlpd","name":"multiply"},"output_index":0,"sequence_number":15} + + + event: response.completed + + data: {"type":"response.completed","response":{"id":"resp_00d64fa806f333310169fab1be69d081a08f8285661855594c","object":"response","created_at":1778037182,"status":"completed","background":false,"completed_at":1778037183,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5-2026-04-23","moderation":null,"output":[{"id":"fc_00d64fa806f333310169fab1bf324481a0852cda3180856290","type":"function_call","status":"completed","arguments":"{\"a\":1231,\"b\":2331}","call_id":"call_sVidsfFJ6zlzRpelrPkTPlpd","name":"multiply"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"effort":"low","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Multiply + two numbers.","name":"multiply","parameters":{"properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":58,"input_tokens_details":{"cached_tokens":0},"output_tokens":23,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":81},"user":null,"metadata":{}},"sequence_number":16} + + + ' + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f74ce85bdf0cb75-DFW + content-type: + - text/event-stream; charset=utf-8 + date: + - Wed, 06 May 2026 03:13:02 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '266' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=dOVwLeU7r7aShRQaxTKiYcu2Y1q8ZJiD8OFt516IQkk-1778037182.353376-1.0.1.1-kDgOFNW1kiTQunazntKDBwpmZh4ai7MGVZI2ZIDLzRXP4MRBfrPevoYSr41Ul_ozGR_VudKgENypl8pRCVaMoqfP66IEVu.Oz7Jz1Le7WzsMCL78NWXm.AbJCZy_KKVD; + HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 06 May 2026 + 03:43:02 GMT + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '39999493' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_996657ef97514aeb93f449d57303514d + status: + code: 200 + message: OK +- request: + body: '{"include":["reasoning.encrypted_content"],"input":[{"role":"user","content":"What + is 1231 * 2331? Use the multiply tool."},{"type":"function_call","call_id":"call_sVidsfFJ6zlzRpelrPkTPlpd","name":"multiply","arguments":"{\"a\": + 1231, \"b\": 2331}"},{"type":"function_call_output","call_id":"call_sVidsfFJ6zlzRpelrPkTPlpd","output":"2869461"}],"model":"gpt-5.5","reasoning":{"effort":"low"},"store":false,"stream":true,"tools":[{"type":"function","name":"multiply","description":"Multiply + two numbers.","parameters":{"properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"],"type":"object"}}]}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '618' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.34.0 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 2.34.0 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.11.15 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: "event: response.created\ndata: {\"type\":\"response.created\",\"response\"\ + :{\"id\":\"resp_0dacb603de1c9e6b0169fab1c2314081a3b1df3cc5c09e0c60\",\"object\"\ + :\"response\",\"created_at\":1778037186,\"status\":\"in_progress\",\"background\"\ + :false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\"\ + :null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\"\ + :null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"\ + parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\"\ + :null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\"\ + :{\"effort\":\"low\",\"summary\":null},\"safety_identifier\":null,\"service_tier\"\ + :\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\"\ + :\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"\ + type\":\"function\",\"description\":\"Multiply two numbers.\",\"name\":\"\ + multiply\",\"parameters\":{\"properties\":{\"a\":{\"type\":\"integer\"},\"\ + b\":{\"type\":\"integer\"}},\"required\":[\"a\",\"b\"],\"type\":\"object\"\ + ,\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\"\ + :0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\"\ + :{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\"\ + :\"response.in_progress\",\"response\":{\"id\":\"resp_0dacb603de1c9e6b0169fab1c2314081a3b1df3cc5c09e0c60\"\ + ,\"object\":\"response\",\"created_at\":1778037186,\"status\":\"in_progress\"\ + ,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\"\ + :0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\"\ + :null,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\"\ + :null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"\ + previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\"\ + :\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":null},\"safety_identifier\"\ + :null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\"\ + :{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\"\ + :\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Multiply two\ + \ numbers.\",\"name\":\"multiply\",\"parameters\":{\"properties\":{\"a\":{\"\ + type\":\"integer\"},\"b\":{\"type\":\"integer\"}},\"required\":[\"a\",\"b\"\ + ],\"type\":\"object\",\"additionalProperties\":false},\"strict\":true}],\"\ + top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"\ + user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\n\ + data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\"\ + :\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\"\ + :2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\"\ + ,\"content_index\":0,\"item_id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"\ + logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\n\ + data: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\"\ + :\"123\",\"item_id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"logprobs\":[],\"obfuscation\":\"yJXGKTBmb2UOz\",\"output_index\":0,\"sequence_number\"\ + :4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\"\ + ,\"content_index\":0,\"delta\":\"1\",\"item_id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"logprobs\":[],\"obfuscation\":\"0bPnrPoF6d7HddV\",\"output_index\":0,\"\ + sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\"\ + :\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \xD7\",\"\ + item_id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\",\"logprobs\"\ + :[],\"obfuscation\":\"ft7PFRXyUN31Dr\",\"output_index\":0,\"sequence_number\"\ + :6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\"\ + ,\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"logprobs\":[],\"obfuscation\":\"HWsKMbb1JWpQBGo\",\"output_index\":0,\"\ + sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\"\ + :\"response.output_text.delta\",\"content_index\":0,\"delta\":\"233\",\"item_id\"\ + :\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\",\"logprobs\":[],\"\ + obfuscation\":\"OyardqgvtiISP\",\"output_index\":0,\"sequence_number\":8}\n\ + \nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\"\ + ,\"content_index\":0,\"delta\":\"1\",\"item_id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"logprobs\":[],\"obfuscation\":\"M5u2aOAAbIF9HaL\",\"output_index\":0,\"\ + sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\"\ + :\"response.output_text.delta\",\"content_index\":0,\"delta\":\" =\",\"item_id\"\ + :\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\",\"logprobs\":[],\"\ + obfuscation\":\"DblLLi7wbRu7Yj\",\"output_index\":0,\"sequence_number\":10}\n\ + \nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\"\ + ,\"content_index\":0,\"delta\":\" **\",\"item_id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"logprobs\":[],\"obfuscation\":\"iiojCebBdYGkO\",\"output_index\":0,\"sequence_number\"\ + :11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\"\ + ,\"content_index\":0,\"delta\":\"2\",\"item_id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"logprobs\":[],\"obfuscation\":\"eJPGA9ScIqJTAvj\",\"output_index\":0,\"\ + sequence_number\":12}\n\nevent: response.output_text.delta\ndata: {\"type\"\ + :\"response.output_text.delta\",\"content_index\":0,\"delta\":\",\",\"item_id\"\ + :\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\",\"logprobs\":[],\"\ + obfuscation\":\"pkBo5DcWhNmQVlb\",\"output_index\":0,\"sequence_number\":13}\n\ + \nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\"\ + ,\"content_index\":0,\"delta\":\"869\",\"item_id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"logprobs\":[],\"obfuscation\":\"6Z8oUB1RMn3DD\",\"output_index\":0,\"sequence_number\"\ + :14}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\"\ + ,\"content_index\":0,\"delta\":\",\",\"item_id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"logprobs\":[],\"obfuscation\":\"Bf7uSiaON5ThxbH\",\"output_index\":0,\"\ + sequence_number\":15}\n\nevent: response.output_text.delta\ndata: {\"type\"\ + :\"response.output_text.delta\",\"content_index\":0,\"delta\":\"461\",\"item_id\"\ + :\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\",\"logprobs\":[],\"\ + obfuscation\":\"qqwxt4JuKvQU6\",\"output_index\":0,\"sequence_number\":16}\n\ + \nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\"\ + ,\"content_index\":0,\"delta\":\"**\",\"item_id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"logprobs\":[],\"obfuscation\":\"XM0rK6J7j52uLE\",\"output_index\":0,\"\ + sequence_number\":17}\n\nevent: response.output_text.done\ndata: {\"type\"\ + :\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"logprobs\":[],\"output_index\":0,\"sequence_number\":18,\"text\":\"1231\ + \ \xD7 2331 = **2,869,461**\"}\n\nevent: response.content_part.done\ndata:\ + \ {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\"\ + :\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\",\"output_index\"\ + :0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"\ + text\":\"1231 \xD7 2331 = **2,869,461**\"},\"sequence_number\":19}\n\nevent:\ + \ response.output_item.done\ndata: {\"type\":\"response.output_item.done\"\ + ,\"item\":{\"id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\"\ + ,\"annotations\":[],\"logprobs\":[],\"text\":\"1231 \xD7 2331 = **2,869,461**\"\ + }],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"\ + sequence_number\":20}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\"\ + ,\"response\":{\"id\":\"resp_0dacb603de1c9e6b0169fab1c2314081a3b1df3cc5c09e0c60\"\ + ,\"object\":\"response\",\"created_at\":1778037186,\"status\":\"completed\"\ + ,\"background\":false,\"completed_at\":1778037187,\"error\":null,\"frequency_penalty\"\ + :0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\"\ + :null,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\"\ + :null,\"output\":[{\"id\":\"msg_0dacb603de1c9e6b0169fab1c2e40081a3857ad788023cb7ea\"\ + ,\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\"\ + ,\"annotations\":[],\"logprobs\":[],\"text\":\"1231 \xD7 2331 = **2,869,461**\"\ + }],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\"\ + :true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\"\ + :null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\"\ + ,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\"\ + ,\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"\ + },\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\"\ + :\"function\",\"description\":\"Multiply two numbers.\",\"name\":\"multiply\"\ + ,\"parameters\":{\"properties\":{\"a\":{\"type\":\"integer\"},\"b\":{\"type\"\ + :\"integer\"}},\"required\":[\"a\",\"b\"],\"type\":\"object\",\"additionalProperties\"\ + :false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\"\ + :\"disabled\",\"usage\":{\"input_tokens\":94,\"input_tokens_details\":{\"\ + cached_tokens\":0},\"output_tokens\":18,\"output_tokens_details\":{\"reasoning_tokens\"\ + :0},\"total_tokens\":112},\"user\":null,\"metadata\":{}},\"sequence_number\"\ + :21}\n\n" + headers: + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + cf-ray: + - 9f74ce9d5cec11fd-ORD + content-type: + - text/event-stream; charset=utf-8 + date: + - Wed, 06 May 2026 03:13:06 GMT + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '353' + openai-project: + - proj_2Y4H7T2XPShczTQx5fqMXdNp + openai-version: + - '2020-10-01' + server: + - cloudflare + set-cookie: + - __cf_bm=YuSyQqrRg_ripxVBGX9ZnLTFASaREsd8RjcORfPvRSE-1778037186.1373405-1.0.1.1-OTt6GIIb.vqG7KdooMAgro_13UWD_y3xibuJnSKYjofIRRPgkkgl3ivm9KWDf85p3Bq6X.FzVkbHHpoqVRfiGNNvdSjs_gxjjGTIEXa8..L8k11d2g6DXPHMJLp1bXFs; + HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 06 May 2026 + 03:43:06 GMT + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '39999457' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_808d2a0863014e248d080b21301d5d58 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_tools/test_tool_use_basic.yaml b/tests/cassettes/test_tools/test_tool_use_basic.yaml new file mode 100644 index 0000000..ec95f0b --- /dev/null +++ b/tests/cassettes/test_tools/test_tool_use_basic.yaml @@ -0,0 +1,333 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"What is 1231 * 2331?"}],"model":"gpt-4o-mini","stream":true,"stream_options":{"include_usage":true},"tools":[{"type":"function","function":{"name":"multiply","description":"Multiply + two numbers.","parameters":{"properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"],"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '351' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.78.0 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: 'data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_1EYWDzueHEp8OsB8jJSEp7WB","type":"function","function":{"name":"multiply","arguments":""}}],"refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"a"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"123"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":",\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"b"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"233"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"}"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":null} + + + data: {"id":"chatcmpl-BWlJBDk2xe66hjff60joVYpXi1hh4","object":"chat.completion.chunk","created":1747148049,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_dbaca60df0","choices":[],"usage":{"prompt_tokens":54,"completion_tokens":20,"total_tokens":74,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}} + + + data: [DONE] + + + ' + headers: + CF-RAY: + - 93f2fd4c4ce5238d-SJC + Connection: + - keep-alive + Content-Type: + - text/event-stream; charset=utf-8 + Date: + - Tue, 13 May 2025 14:54:09 GMT + Server: + - cloudflare + Set-Cookie: + - __cf_bm=ys1VX4Q4znOtsubzjx.nHCe9_hPEK_9fLmKeIYZd9LE-1747148049-1.0.1.1-c_hjWtmrr1A3GwMaBXzfhWwaX3EZ2E5Iz_5j.KkgJ3qqPA8vdd2tTpYVL1KRgkOWgWKSUHvYx9I62zt4yf.e9GO3PWHu60ji3ZEjh81.uNc; + path=/; expires=Tue, 13-May-25 15:24:09 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=hnWsKFQeNuxEVe5VU69rl9nk7g.ahx0f.wEzyB.f7Kk-1747148049996-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '547' + openai-version: + - '2020-10-01' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + x-envoy-upstream-service-time: + - '552' + x-ratelimit-limit-requests: + - '30000' + x-ratelimit-limit-tokens: + - '150000000' + x-ratelimit-remaining-requests: + - '29999' + x-ratelimit-remaining-tokens: + - '149999993' + x-ratelimit-reset-requests: + - 2ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_c3e995e7a86953713a6dc1b17e399fd5 + status: + code: 200 + message: OK +- request: + body: '{"messages":[{"role":"user","content":"What is 1231 * 2331?"},{"role":"assistant","content":""},{"role":"assistant","tool_calls":[{"type":"function","id":"call_1EYWDzueHEp8OsB8jJSEp7WB","function":{"name":"multiply","arguments":"{\"a\": + 1231, \"b\": 2331}"}}]},{"role":"tool","tool_call_id":"call_1EYWDzueHEp8OsB8jJSEp7WB","content":"2869461"}],"model":"gpt-4o-mini","stream":true,"stream_options":{"include_usage":true},"tools":[{"type":"function","function":{"name":"multiply","description":"Multiply + two numbers.","parameters":{"properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"],"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '633' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.78.0 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: 'data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":"The"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":" + result"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":" + of"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":" + \\("},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":" + "},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":"123"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":"1"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":" + \\"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":"times"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":" + "},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":"233"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":"1"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":" + \\"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":")"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":" + is"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":" + \\("},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":" + "},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":"2"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":"869"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":"461"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":" + \\"},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{"content":")."},"logprobs":null,"finish_reason":null}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null} + + + data: {"id":"chatcmpl-BWlJCN7VZTtSHROczp0AbrjFGhRMA","object":"chat.completion.chunk","created":1747148050,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_0392822090","choices":[],"usage":{"prompt_tokens":87,"completion_tokens":26,"total_tokens":113,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}} + + + data: [DONE] + + + ' + headers: + CF-RAY: + - 93f2fd522938eb20-SJC + Connection: + - keep-alive + Content-Type: + - text/event-stream; charset=utf-8 + Date: + - Tue, 13 May 2025 14:54:10 GMT + Server: + - cloudflare + Set-Cookie: + - __cf_bm=fdLfMrUkpH5wVR0YRkrP2U10JK7jFW._HUYimqZukgg-1747148050-1.0.1.1-aQIjrwFbyIcTr_HW09RscO7okcLFLbvptmCQBFweX4SskJ3FKciprVe5ffCuvcWn03rURb.wLkcTAzQzoZIdOv6OBYcJu5vMutdjPs9t0EI; + path=/; expires=Tue, 13-May-25 15:24:10 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=X620Mz_MZZuz8JBE23JWZUpAD7vTnI.UtEcQjGZZPBA-1747148050599-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '221' + openai-version: + - '2020-10-01' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + x-envoy-upstream-service-time: + - '225' + x-ratelimit-limit-requests: + - '30000' + x-ratelimit-limit-tokens: + - '150000000' + x-ratelimit-remaining-requests: + - '29999' + x-ratelimit-remaining-tokens: + - '149999987' + x-ratelimit-reset-requests: + - 2ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_51f3397f64a0302e34a4d78ea85e0585 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_tools/test_tool_use_chain_of_two_calls.yaml b/tests/cassettes/test_tools/test_tool_use_chain_of_two_calls.yaml new file mode 100644 index 0000000..ff7b099 --- /dev/null +++ b/tests/cassettes/test_tools/test_tool_use_chain_of_two_calls.yaml @@ -0,0 +1,330 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"Can the country of Crumpet have + dragons? Answer with only YES or NO"}],"model":"gpt-4o-mini","stream":false,"tools":[{"type":"function","function":{"name":"lookup_population","description":"Returns + the current population of the specified fictional country","parameters":{"properties":{"country":{"type":"string"}},"required":["country"],"type":"object"}}},{"type":"function","function":{"name":"can_have_dragons","description":"Returns + True if the specified population can have dragons, False otherwise","parameters":{"properties":{"population":{"type":"integer"}},"required":["population"],"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '650' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.78.0 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jFPBjtowEL3nK6w5kyrJ0gI5slXppWzbZbdqyyoyziS4OLZrO1sQ4t+r + GEjCLpWaQ2TNm/fmzYy9DwgBnkNKgK2pY5UW4fSbns1n+ee7+eLP9uPmx+N29Zu5L/fT99VCwqBh + qNUvZO7MesNUpQU6rk4wM0gdNqrxaDiK390kb2MPVCpH0dBK7cKhCisueZhEyTCMRmE8PrHXijO0 + kJKfASGE7P2/8Slz3EJKosE5UqG1tERI2yRCwCjRRIBay62j0sGgA5mSDmVjXdZC9ACnlMgYFaIr + fPz2vXM3LCpEtlh8Hz98mKuRmd/Su+nD3Imv98+fZr16R+md9oaKWrJ2SD28jacvihECklaeK5Ta + 1DrTSteCXhEhBKgp6wqlaxqA/RKYqqUzuyWkS7g1daXRLeEAF7RDcO381JuLwaK2VLweGJVSOW/F + T+zphBza5QhVaqNW9gUVCi65XWcGqfU990cfnI14C1BfbBe0UZV2mVMb9EUnyVEUugvYgfHoBDrl + qOjFo8ngilyWo6Pcb7+9cIyyNeYdtbt4tM656gFBr/XXbq5pH9vnsvwf+Q5gDLXDPNMGc84uO+7S + DDbv819p7ZC9YbBonjnDzHE0zTpyLGgtjq8G7M46rLKCyxKNNtw/HSh0Ft1MknGSRJMIgkPwFwAA + //8DALof6VxIBAAA + headers: + CF-RAY: + - 93f47072dde6f88d-IAD + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Tue, 13 May 2025 19:07:32 GMT + Server: + - cloudflare + Set-Cookie: + - __cf_bm=vfHkbLfwVTTGPkFT0I4U0xn5CHQZYIpOutDV4z7NRlA-1747163252-1.0.1.1-kj_JiiyNxn9AWCWisV6.pYNShKVqqT0Foicji2.ZLNaAkHm5VEwac0QjxVhCiWQs9Xp_wvkeTzrgVxmD8bkzDwTPn96U.81YERXZda3_m18; + path=/; expires=Tue, 13-May-25 19:37:32 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=SQgXKMy2qkeOsbwwTl62blvuirTS_TkZSvEOztbYIlI-1747163252293-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '574' + openai-version: + - '2020-10-01' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + x-envoy-upstream-service-time: + - '591' + x-ratelimit-limit-requests: + - '30000' + x-ratelimit-limit-tokens: + - '150000000' + x-ratelimit-remaining-requests: + - '29999' + x-ratelimit-remaining-tokens: + - '149999981' + x-ratelimit-reset-requests: + - 2ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_1e7dabaf1f0dba1ec89a134d3bde8476 + status: + code: 200 + message: OK +- request: + body: '{"messages":[{"role":"user","content":"Can the country of Crumpet have + dragons? Answer with only YES or NO"},{"role":"assistant","tool_calls":[{"type":"function","id":"call_TTY8UFNo7rNCaOBUNtlRSvMG","function":{"name":"lookup_population","arguments":"{\"country\": + \"Crumpet\"}"}}]},{"role":"tool","tool_call_id":"call_TTY8UFNo7rNCaOBUNtlRSvMG","content":"123124"}],"model":"gpt-4o-mini","stream":false,"tools":[{"type":"function","function":{"name":"lookup_population","description":"Returns + the current population of the specified fictional country","parameters":{"properties":{"country":{"type":"string"}},"required":["country"],"type":"object"}}},{"type":"function","function":{"name":"can_have_dragons","description":"Returns + True if the specified population can have dragons, False otherwise","parameters":{"properties":{"population":{"type":"integer"}},"required":["population"],"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '906' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.78.0 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAA4xTTYvbMBC9+1eIOcfFH2k+fNyWlEIPLaWkm+5itNLY0UaWVEkOzYb892J7YzvZ + FOqDEfPmvXkzIx0DQkBwyAiwLfWsMjK8W5tP39a7es+i1YZvNs9fXxb4Jf7A/R1bwaRh6KdnZP7M + esd0ZSR6oVUHM4vUY6Maz6fzeJYm76ctUGmOsqGVxodTHVZCiTCJkmkYzcN48creasHQQUZ+BYQQ + cmz/jU/F8Q9kJJqcIxU6R0uErE8iBKyWTQSoc8J5qjxMBpBp5VE11lUt5QjwWsucUSmHwt13HJ2H + YVEpc/p7+eMgvq92Lz9n68U9Z2n6UX9e3o/qddIH0xoqasX6IY3wPp5dFSMEFK2wK6jyLd1jzi0t + tXJXGoQAtWVdofKNfzg+gNGmlrTRfYAsTtI4mZ7ggnQKbp0fR0OxWNSOyrfTokpp34q343p8RU79 + ZqQujdVP7ooKhVDCbXOL1LUNj+cenI20FqC+WC0Yqyvjc6932BaN40WnCsP1G6Fn0GtP5SieziY3 + 9HKOnop29/11Y5RtkQ/U4drRmgs9AoJR72/d3NLu+heq/B/5AWAMjUeeG4tcsMuOhzSLzev8V1o/ + 5dYwOLR7wTD3Am2zD44FrWX3ZsAdnMcqL4Qq0Ror2ocDhcmjdJkskiRaRhCcgr8AAAD//wMAmw02 + QkYEAAA= + headers: + CF-RAY: + - 93f47082ba71d640-IAD + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Tue, 13 May 2025 19:07:35 GMT + Server: + - cloudflare + Set-Cookie: + - __cf_bm=LL6YtOWVW4fA687_GIMcuJC7CM2I.uKx1vGaNkjFTgo-1747163255-1.0.1.1-qML6IsLM49e2bg7zp0uGqn3.JTJP5KlFYfb8o3v9LzyLb.cYoFBXn5te83Wxl5kVjDiXU2vH.QTFQu953KNx87LwsMkI2ZxTvH58oZWAawg; + path=/; expires=Tue, 13-May-25 19:37:35 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=QOa3sx0F4_nAYKtjmx9ux7qfIsyipGZq94AL_SWd2ac-1747163255176-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '575' + openai-version: + - '2020-10-01' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + x-envoy-upstream-service-time: + - '587' + x-ratelimit-limit-requests: + - '30000' + x-ratelimit-limit-tokens: + - '150000000' + x-ratelimit-remaining-requests: + - '29999' + x-ratelimit-remaining-tokens: + - '149999976' + x-ratelimit-reset-requests: + - 2ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_66cc3b2bbe3be82a37d29fba7672d82b + status: + code: 200 + message: OK +- request: + body: '{"messages":[{"role":"user","content":"Can the country of Crumpet have + dragons? Answer with only YES or NO"},{"role":"assistant","tool_calls":[{"type":"function","id":"call_TTY8UFNo7rNCaOBUNtlRSvMG","function":{"name":"lookup_population","arguments":"{\"country\": + \"Crumpet\"}"}}]},{"role":"tool","tool_call_id":"call_TTY8UFNo7rNCaOBUNtlRSvMG","content":"123124"},{"role":"assistant","tool_calls":[{"type":"function","id":"call_aq9UyiSFkzX6W8Ydc33DoI9Y","function":{"name":"can_have_dragons","arguments":"{\"population\": + 123124}"}}]},{"role":"tool","tool_call_id":"call_aq9UyiSFkzX6W8Ydc33DoI9Y","content":"true"}],"model":"gpt-4o-mini","stream":false,"tools":[{"type":"function","function":{"name":"lookup_population","description":"Returns + the current population of the specified fictional country","parameters":{"properties":{"country":{"type":"string"}},"required":["country"],"type":"object"}}},{"type":"function","function":{"name":"can_have_dragons","description":"Returns + True if the specified population can have dragons, False otherwise","parameters":{"properties":{"population":{"type":"integer"}},"required":["population"],"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '1157' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.78.0 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jJJBb9swDIXv/hUCz/HgOGmd5NYW2447bNjQDIWhSLSjThYFiS42FPnv + g+w0drcO2EUHfXzUexSfMyHAaNgJUEfJqvM2v/3mP37Z31ebq69xb/zdp+Jw8/Sh2lf8qG9gkRR0 + eETFL6p3ijpvkQ25EauAkjF1XVbranm9Kq+qAXSk0SZZ6zlfU94ZZ/KyKNd5UeXLzVl9JKMwwk58 + z4QQ4nk4k0+n8SfsRLF4uekwRtki7C5FQkAgm25AxmgiS8ewmKAix+gG6/fvP89JwKaPMrlzvbUz + IJ0jlind4OnhTE4XF5ZaH+gQ/5BCY5yJxzqgjOTSi5HJw0BPmRAPQ9r+VQDwgTrPNdMPHJ5brq/H + fjANeaKrM2Niaeei7eKNdrVGlsbG2bhASXVEPUmn2cpeG5qBbBb6bzNv9R6DG9f+T/sJKIWeUdc+ + oDbqdeCpLGBawX+VXYY8GIaI4ckorNlgSB+hsZG9HRcD4q/I2NWNcS0GH8y4HY2vi9W23JRlsS0g + O2W/AQAA//8DAFbEZUIrAwAA + headers: + CF-RAY: + - 93f47096cf15d6e9-IAD + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Tue, 13 May 2025 19:07:37 GMT + Server: + - cloudflare + Set-Cookie: + - __cf_bm=EDR.bZeRmrWVNTWef5aAJ2C5NT7yIBHq_6NzNGXNlX0-1747163257-1.0.1.1-YuS4Hj.Ncp4eOrYNT5L7AncdqT5Xn8a2DTxCka1HKKBGKdT8k70yvNTA3wMlQyVPxGD3HSCysY0a1n1zCkNs._TQe9hWOuoIDG9LtD9MBr4; + path=/; expires=Tue, 13-May-25 19:37:37 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=3Xqq8l5nvU4mfyEz4.llgkHC3jY.IBLFTJrD76P7UsY-1747163257692-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - user-r3e61fpak04cbaokp5buoae4 + openai-processing-ms: + - '222' + openai-version: + - '2020-10-01' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + x-envoy-upstream-service-time: + - '227' + x-ratelimit-limit-requests: + - '30000' + x-ratelimit-limit-tokens: + - '150000000' + x-ratelimit-remaining-requests: + - '29999' + x-ratelimit-remaining-tokens: + - '149999974' + x-ratelimit-reset-requests: + - 2ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_d157a5a0f4b64776bc387ccab624e664 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_tools_streaming/test_tools_streaming_variant_a.yaml b/tests/cassettes/test_tools_streaming/test_tools_streaming_variant_a.yaml new file mode 100644 index 0000000..b7b0359 --- /dev/null +++ b/tests/cassettes/test_tools_streaming/test_tools_streaming_variant_a.yaml @@ -0,0 +1,154 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"What is the current llm version?"}],"model":"gpt-4.1-mini","stream":true,"stream_options":{"include_usage":true},"tools":[{"type":"function","function":{"name":"llm_version","description":"Return + the installed version of llm","parameters":{"properties":{},"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '315' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: 'data: {"id":"gen-1753242299-QZRAt5HJHd1ptY8sdS0s","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242299,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242299-QZRAt5HJHd1ptY8sdS0s","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242299,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242299-QZRAt5HJHd1ptY8sdS0s","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242299,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"0","type":"function","function":{"name":"llm_version","arguments":""}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242299-QZRAt5HJHd1ptY8sdS0s","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242299,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"0","type":"function","function":{"name":"llm_version","arguments":"{}"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242299-QZRAt5HJHd1ptY8sdS0s","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242299,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":57,"completion_tokens":17,"total_tokens":74,"cost":0.00007159,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0},"cost_details":{"upstream_inference_cost":null},"completion_tokens_details":{"reasoning_tokens":0}}} + + + data: [DONE] + + + ' + headers: + Connection: + - keep-alive + Content-Type: + - text/event-stream; charset=utf-8 + Date: + - Tue, 23 Jul 2025 14:54:09 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + status: + code: 200 + message: OK +- request: + body: '{"messages":[{"role":"user","content":"What is the current llm version?"},{"role":"assistant","content":""},{"role":"assistant","tool_calls":[{"type":"function","id":"0","function":{"name":"llm_version","arguments":"{}"}}]},{"role":"tool","tool_call_id":"0","content":"0.fixed-version"}],"model":"gpt-4.1-mini","stream":true,"stream_options":{"include_usage":true},"tools":[{"type":"function","function":{"name":"llm_version","description":"Return + the installed version of llm","parameters":{"properties":{},"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '517' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: 'data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"The"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + current"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + version"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + of"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + *"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"ll"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"m"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"*"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + is"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + **"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"0"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"fixed-version"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"**."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":107,"completion_tokens":15,"total_tokens":122,"cost":0.0001017,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0},"cost_details":{"upstream_inference_cost":null},"completion_tokens_details":{"reasoning_tokens":0}}} + + + data: [DONE] + + + ' + headers: + Connection: + - keep-alive + Content-Type: + - text/event-stream; charset=utf-8 + Date: + - Tue, 23 Jul 2025 14:54:10 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_tools_streaming/test_tools_streaming_variant_b.yaml b/tests/cassettes/test_tools_streaming/test_tools_streaming_variant_b.yaml new file mode 100644 index 0000000..dc63a4b --- /dev/null +++ b/tests/cassettes/test_tools_streaming/test_tools_streaming_variant_b.yaml @@ -0,0 +1,151 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"What is the current llm version?"}],"model":"gpt-4.1-mini","stream":true,"stream_options":{"include_usage":true},"tools":[{"type":"function","function":{"name":"llm_version","description":"Return + the installed version of llm","parameters":{"properties":{},"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '315' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: 'data: {"id":"gen-1753242299-QZRAt5HJHd1ptY8sdS0s","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242299,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242299-QZRAt5HJHd1ptY8sdS0s","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242299,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242299-QZRAt5HJHd1ptY8sdS0s","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242299,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"0","type":"function","function":{"name":"llm_version","arguments":"{}"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242299-QZRAt5HJHd1ptY8sdS0s","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242299,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":57,"completion_tokens":17,"total_tokens":74,"cost":0.00007159,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0},"cost_details":{"upstream_inference_cost":null},"completion_tokens_details":{"reasoning_tokens":0}}} + + + data: [DONE] + + + ' + headers: + Connection: + - keep-alive + Content-Type: + - text/event-stream; charset=utf-8 + Date: + - Tue, 23 Jul 2025 14:54:09 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + status: + code: 200 + message: OK +- request: + body: '{"messages":[{"role":"user","content":"What is the current llm version?"},{"role":"assistant","content":""},{"role":"assistant","tool_calls":[{"type":"function","id":"0","function":{"name":"llm_version","arguments":"{}"}}]},{"role":"tool","tool_call_id":"0","content":"0.fixed-version"}],"model":"gpt-4.1-mini","stream":true,"stream_options":{"include_usage":true},"tools":[{"type":"function","function":{"name":"llm_version","description":"Return + the installed version of llm","parameters":{"properties":{},"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '517' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: 'data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"The"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + current"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + version"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + of"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + *"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"ll"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"m"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"*"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + is"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + **"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"0"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"fixed-version"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"**."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753242300-j60LWi6MpN4lMZw1zTHK","provider":"Moonshot AI","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":107,"completion_tokens":15,"total_tokens":122,"cost":0.0001017,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0},"cost_details":{"upstream_inference_cost":null},"completion_tokens_details":{"reasoning_tokens":0}}} + + + data: [DONE] + + + ' + headers: + Connection: + - keep-alive + Content-Type: + - text/event-stream; charset=utf-8 + Date: + - Tue, 23 Jul 2025 14:54:10 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_tools_streaming/test_tools_streaming_variant_c.yaml b/tests/cassettes/test_tools_streaming/test_tools_streaming_variant_c.yaml new file mode 100644 index 0000000..ed8cafb --- /dev/null +++ b/tests/cassettes/test_tools_streaming/test_tools_streaming_variant_c.yaml @@ -0,0 +1,149 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"What is the current llm version?"}],"model":"gpt-4.1-mini","stream":true,"stream_options":{"include_usage":true},"tools":[{"type":"function","function":{"name":"llm_version","description":"Return + the installed version of llm","parameters":{"properties":{},"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '315' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: ' + data: {"id":"gen-1753248108-FGOxpkEzFEwhNKSPpI4a","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248108,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753248108-FGOxpkEzFEwhNKSPpI4a","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248108,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"llm_version:0","type":"function","function":{"name":"llm_version"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753248108-FGOxpkEzFEwhNKSPpI4a","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248108,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"function":{"arguments":"{}"},"type":"function"}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753248108-FGOxpkEzFEwhNKSPpI4a","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248108,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"tool_calls","native_finish_reason":"tool_calls","logprobs":null}],"system_fingerprint":"fpv0_170758dd"} + + + data: {"id":"gen-1753248108-FGOxpkEzFEwhNKSPpI4a","provider":"Novita","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248108,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":56,"completion_tokens":12,"total_tokens":68,"cost":0.00005952,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0},"cost_details":{"upstream_inference_cost":null},"completion_tokens_details":{"reasoning_tokens":0}}} + + + data: [DONE] + + + ' + headers: + Connection: + - keep-alive + Content-Type: + - text/event-stream; charset=utf-8 + Date: + - Tue, 23 Jul 2025 14:54:09 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + status: + code: 200 + message: OK +- request: + body: '{"messages":[{"role":"user","content":"What is the current llm version?"},{"role":"assistant","content":""},{"role":"assistant","tool_calls":[{"type":"function","id":"0","function":{"name":"llm_version","arguments":"{}"}}]},{"role":"tool","tool_call_id":"0","content":"0.fixed-version"}],"model":"gpt-4.1-mini","stream":true,"stream_options":{"include_usage":true},"tools":[{"type":"function","function":{"name":"llm_version","description":"Return + the installed version of llm","parameters":{"properties":{},"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '517' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: 'data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":"The installed"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":" version"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":" of"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":" LL"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":"M"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":" on"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":" this"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":" system"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":" is"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":" "},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":"0"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":"."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":"fixed-version"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":"."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]} + + + data: {"id":"gen-1753248104-uf1xqJDBrAUCJ4g8apK8","provider":"Fireworks","model":"moonshotai/kimi-k2","object":"chat.completion.chunk","created":1753248104,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":105,"completion_tokens":16,"total_tokens":121,"cost":0.000103,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0},"cost_details":{"upstream_inference_cost":null},"completion_tokens_details":{"reasoning_tokens":0}}} + + + data: [DONE] + + + ' + headers: + Connection: + - keep-alive + Content-Type: + - text/event-stream; charset=utf-8 + Date: + - Tue, 23 Jul 2025 14:54:10 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_tools_streaming/test_tools_streaming_variant_d.yaml b/tests/cassettes/test_tools_streaming/test_tools_streaming_variant_d.yaml new file mode 100644 index 0000000..01c5492 --- /dev/null +++ b/tests/cassettes/test_tools_streaming/test_tools_streaming_variant_d.yaml @@ -0,0 +1,145 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"What is the current llm version?"}],"model":"muse-spark-1.1","stream":true,"stream_options":{"include_usage":true},"tools":[{"type":"function","function":{"name":"llm_version","description":"Return + the installed version of llm","parameters":{"properties":{},"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '315' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: 'data: {"id":"gen-1753242299-DdArgsNullVariantD00","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242299,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242299-DdArgsNullVariantD00","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242299,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"0","type":"function","function":{"name":"llm_version","arguments":null}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242299-DdArgsNullVariantD00","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242299,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"tool_calls","native_finish_reason":"tool_calls","logprobs":null}],"usage":{"prompt_tokens":57,"completion_tokens":17,"total_tokens":74,"cost":0.00007159,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0},"cost_details":{"upstream_inference_cost":null},"completion_tokens_details":{"reasoning_tokens":0}}} + + + data: [DONE] + + + ' + headers: + Connection: + - keep-alive + Content-Type: + - text/event-stream; charset=utf-8 + Date: + - Tue, 23 Jul 2025 14:54:09 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + status: + code: 200 + message: OK +- request: + body: '{"messages":[{"role":"user","content":"What is the current llm version?"},{"role":"assistant","content":""},{"role":"assistant","tool_calls":[{"type":"function","id":"0","function":{"name":"llm_version","arguments":"{}"}}]},{"role":"tool","tool_call_id":"0","content":"0.fixed-version"}],"model":"muse-spark-1.1","stream":true,"stream_options":{"include_usage":true},"tools":[{"type":"function","function":{"name":"llm_version","description":"Return + the installed version of llm","parameters":{"properties":{},"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '517' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.78.0 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: 'data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"The"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + current"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + version"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + of"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + *"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"ll"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"m"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"*"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + is"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":" + **"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"0"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"fixed-version"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":"**."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}],"system_fingerprint":""} + + + data: {"id":"gen-1753242300-DdArgsNullVariantD01","provider":"Meta","model":"muse-spark-1.1","object":"chat.completion.chunk","created":1753242300,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":107,"completion_tokens":15,"total_tokens":122,"cost":0.0001017,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0},"cost_details":{"upstream_inference_cost":null},"completion_tokens_details":{"reasoning_tokens":0}}} + + + data: [DONE] + + + ' + headers: + Connection: + - keep-alive + Content-Type: + - text/event-stream; charset=utf-8 + Date: + - Tue, 23 Jul 2025 14:54:10 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + status: + code: 200 + message: OK +version: 1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f26b4e9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,502 @@ +import importlib.metadata +import pytest +import sqlite_utils +import json +import sqlite3 +import llm +import llm_echo +from llm.plugins import pm +from pydantic import Field +from pytest_httpx import IteratorStream +from typing import Optional + + +def pytest_configure(config): + import sys + + sys._called_from_test = True + + +def pytest_report_header(config): + conn = sqlite3.connect(":memory:") + version = conn.execute("select sqlite_version()").fetchone()[0] + conn.close() + sqlite_utils_version = importlib.metadata.version("sqlite-utils") + return [ + "SQLite: {}".format(version), + "sqlite-utils: {}".format(sqlite_utils_version), + ] + + +@pytest.fixture +def user_path(tmpdir): + dir = tmpdir / "llm.datasette.io" + dir.mkdir() + return dir + + +@pytest.fixture +def logs_db(user_path): + return sqlite_utils.Database(str(user_path / "logs.db")) + + +@pytest.fixture +def user_path_with_embeddings(user_path): + path = str(user_path / "embeddings.db") + db = sqlite_utils.Database(path) + collection = llm.Collection("demo", db, model_id="embed-demo") + collection.embed("1", "hello world", store=True) + collection.embed("2", "goodbye world", store=True) + + +@pytest.fixture +def templates_path(user_path): + dir = user_path / "templates" + dir.mkdir() + return dir + + +@pytest.fixture(autouse=True) +def env_setup(monkeypatch, user_path): + monkeypatch.setenv("LLM_USER_PATH", str(user_path)) + + +class MockModel(llm.Model): + model_id = "mock" + attachment_types = {"image/png", "audio/wav"} + can_stream = True + supports_schema = True + supports_tools = True + + class Options(llm.Options): + max_tokens: Optional[int] = Field( + description="Maximum number of tokens to generate.", default=None + ) + + def __init__(self): + self.history = [] + self._queue = [] + self.resolved_model_name = None + + def enqueue(self, messages): + assert isinstance(messages, list) + self._queue.append(messages) + + def execute(self, prompt, stream, response, conversation): + self.history.append((prompt, stream, response, conversation)) + gathered = [] + while True: + try: + messages = self._queue.pop(0) + for message in messages: + gathered.append(message) + yield message + break + except IndexError: + break + response.set_usage( + input=len((prompt.prompt or "").split()), output=len(gathered) + ) + if self.resolved_model_name is not None: + response.set_resolved_model(self.resolved_model_name) + + +class MockKeyModel(llm.KeyModel): + model_id = "mock_key" + needs_key = "mock" + + def execute(self, prompt, stream, response, conversation, key): + return [f"key: {key}"] + + +class MockAsyncKeyModel(llm.AsyncKeyModel): + model_id = "mock_key" + needs_key = "mock" + + async def execute(self, prompt, stream, response, conversation, key): + yield f"async, key: {key}" + + +class AsyncMockModel(llm.AsyncModel): + model_id = "mock" + can_stream = True + supports_schema = True + + def __init__(self): + self.history = [] + self._queue = [] + self.resolved_model_name = None + + def enqueue(self, messages): + assert isinstance(messages, list) + self._queue.append(messages) + + async def execute(self, prompt, stream, response, conversation): + self.history.append((prompt, stream, response, conversation)) + gathered = [] + while True: + try: + messages = self._queue.pop(0) + for message in messages: + gathered.append(message) + yield message + break + except IndexError: + break + response.set_usage( + input=len((prompt.prompt or "").split()), output=len(gathered) + ) + if self.resolved_model_name is not None: + response.set_resolved_model(self.resolved_model_name) + + +class EmbedDemo(llm.EmbeddingModel): + model_id = "embed-demo" + batch_size = 10 + supports_binary = True + + def __init__(self): + self.embedded_content = [] + + def embed_batch(self, texts): + if not hasattr(self, "batch_count"): + self.batch_count = 0 + self.batch_count += 1 + for text in texts: + self.embedded_content.append(text) + words = text.split()[:16] + embedding = [len(word) for word in words] + # Pad with 0 up to 16 words + embedding += [0] * (16 - len(embedding)) + yield embedding + + +class EmbedBinaryOnly(EmbedDemo): + model_id = "embed-binary-only" + supports_text = False + supports_binary = True + + +class EmbedTextOnly(EmbedDemo): + model_id = "embed-text-only" + supports_text = True + supports_binary = False + + +@pytest.fixture +def embed_demo(): + return EmbedDemo() + + +@pytest.fixture +def mock_model(): + return MockModel() + + +@pytest.fixture +def async_mock_model(): + return AsyncMockModel() + + +@pytest.fixture +def mock_key_model(): + return MockKeyModel() + + +@pytest.fixture +def mock_async_key_model(): + return MockAsyncKeyModel() + + +@pytest.fixture(autouse=True) +def register_embed_demo_model(embed_demo, mock_model, async_mock_model): + class MockModelsPlugin: + __name__ = "MockModelsPlugin" + + @llm.hookimpl + def register_embedding_models(self, register): + register(embed_demo) + register(EmbedBinaryOnly()) + register(EmbedTextOnly()) + + @llm.hookimpl + def register_models(self, register): + register(mock_model, async_model=async_mock_model) + + pm.register(MockModelsPlugin(), name="undo-mock-models-plugin") + try: + yield + finally: + pm.unregister(name="undo-mock-models-plugin") + + +@pytest.fixture(autouse=True) +def register_echo_model(): + class EchoModelPlugin: + __name__ = "EchoModelPlugin" + + @llm.hookimpl + def register_models(self, register): + register(llm_echo.Echo(), llm_echo.EchoAsync()) + + pm.register(EchoModelPlugin(), name="undo-EchoModelPlugin") + try: + yield + finally: + pm.unregister(name="undo-EchoModelPlugin") + + +@pytest.fixture +def mocked_openai_chat(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-4o-mini", + "usage": {}, + "choices": [{"message": {"content": "Bob, Alice, Eve"}}], + }, + headers={"Content-Type": "application/json"}, + ) + return httpx_mock + + +@pytest.fixture +def mocked_openai_chat_returning_fenced_code(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-4o-mini", + "usage": {}, + "choices": [ + { + "message": { + "content": "Code:\n\n````javascript\nfunction foo() {\n return 'bar';\n}\n````\nDone.", + } + } + ], + }, + headers={"Content-Type": "application/json"}, + ) + return httpx_mock + + +def stream_events(): + for delta, finish_reason in ( + ({"role": "assistant", "content": ""}, None), + ({"content": "Hi"}, None), + ({"content": "."}, None), + ({}, "stop"), + ): + yield "data: {}\n\n".format( + json.dumps( + { + "id": "chat-1", + "object": "chat.completion.chunk", + "created": 1695096940, + "model": "gpt-3.5-turbo-0613", + "choices": [ + {"index": 0, "delta": delta, "finish_reason": finish_reason} + ], + } + ) + ).encode("utf-8") + yield "data: [DONE]\n\n".encode("utf-8") + + +@pytest.fixture +def mocked_openai_chat_stream(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + stream=IteratorStream(stream_events()), + headers={"Content-Type": "text/event-stream"}, + ) + + +@pytest.fixture +def mocked_openai_completion(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/completions", + json={ + "id": "cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7", + "object": "text_completion", + "created": 1589478378, + "model": "gpt-3.5-turbo-instruct", + "choices": [ + { + "text": "\n\nThis is indeed a test", + "index": 0, + "logprobs": None, + "finish_reason": "length", + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 7, "total_tokens": 12}, + }, + headers={"Content-Type": "application/json"}, + ) + return httpx_mock + + +def stream_completion_events(): + choices_chunks = [ + [ + { + "text": "\n\n", + "index": 0, + "logprobs": { + "tokens": ["\n\n"], + "token_logprobs": [-0.6], + "top_logprobs": [{"\n\n": -0.6, "\n": -1.9}], + "text_offset": [16], + }, + "finish_reason": None, + } + ], + [ + { + "text": "Hi", + "index": 0, + "logprobs": { + "tokens": ["Hi"], + "token_logprobs": [-1.1], + "top_logprobs": [{"Hi": -1.1, "Hello": -0.7}], + "text_offset": [18], + }, + "finish_reason": None, + } + ], + [ + { + "text": ".", + "index": 0, + "logprobs": { + "tokens": ["."], + "token_logprobs": [-1.1], + "top_logprobs": [{".": -1.1, "!": -0.9}], + "text_offset": [20], + }, + "finish_reason": None, + } + ], + [ + { + "text": "", + "index": 0, + "logprobs": { + "tokens": [], + "token_logprobs": [], + "top_logprobs": [], + "text_offset": [], + }, + "finish_reason": "stop", + } + ], + ] + + for choices in choices_chunks: + yield "data: {}\n\n".format( + json.dumps( + { + "id": "cmpl-80MdSaou7NnPuff5ZyRMysWBmgSPS", + "object": "text_completion", + "created": 1695097702, + "choices": choices, + "model": "gpt-3.5-turbo-instruct", + } + ) + ).encode("utf-8") + yield "data: [DONE]\n\n".encode("utf-8") + + +@pytest.fixture +def mocked_openai_completion_logprobs_stream(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/completions", + stream=IteratorStream(stream_completion_events()), + headers={"Content-Type": "text/event-stream"}, + ) + return httpx_mock + + +@pytest.fixture +def mocked_openai_completion_logprobs(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/completions", + json={ + "id": "cmpl-80MeBfKJutM0uMNJkRrebJLeP3bxL", + "object": "text_completion", + "created": 1695097747, + "model": "gpt-3.5-turbo-instruct", + "choices": [ + { + "text": "\n\nHi.", + "index": 0, + "logprobs": { + "tokens": ["\n\n", "Hi", "1"], + "token_logprobs": [-0.6, -1.1, -0.9], + "top_logprobs": [ + {"\n\n": -0.6, "\n": -1.9}, + {"Hi": -1.1, "Hello": -0.7}, + {".": -0.9, "!": -1.1}, + ], + "text_offset": [16, 18, 20], + }, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + }, + headers={"Content-Type": "application/json"}, + ) + return httpx_mock + + +@pytest.fixture +def mocked_localai(httpx_mock): + httpx_mock.add_response( + method="POST", + url="http://localai.localhost/chat/completions", + json={ + "model": "orca", + "usage": {}, + "choices": [{"message": {"content": "Bob, Alice, Eve"}}], + }, + headers={"Content-Type": "application/json"}, + ) + httpx_mock.add_response( + method="POST", + url="http://localai.localhost/completions", + json={ + "model": "completion-babbage", + "usage": {}, + "choices": [{"text": "Hello"}], + }, + headers={"Content-Type": "application/json"}, + ) + return httpx_mock + + +@pytest.fixture +def collection(): + collection = llm.Collection("test", model_id="embed-demo") + collection.embed(1, "hello world") + collection.embed(2, "goodbye world") + return collection + + +@pytest.fixture(scope="module") +def vcr_config(): + return {"filter_headers": ["Authorization"]} + + +def extract_braces(s): + first = s.find("{") + last = s.rfind("}") + if first != -1 and last != -1 and first < last: + return s[first : last + 1] + return None diff --git a/tests/test-llm-load-plugins.sh b/tests/test-llm-load-plugins.sh new file mode 100755 index 0000000..7629798 --- /dev/null +++ b/tests/test-llm-load-plugins.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# This should only run in environments where both +# llm-cluster and llm-mistral are installed + +PLUGINS=$(llm plugins) +echo "$PLUGINS" | jq 'any(.[]; .name == "llm-mistral")' | \ + grep -q true || ( \ + echo "Test failed: llm-mistral not found" && \ + exit 1 \ + ) +# With the LLM_LOAD_PLUGINS we should not see that +PLUGINS2=$(LLM_LOAD_PLUGINS=llm-cluster llm plugins) +echo "$PLUGINS2" | jq 'any(.[]; .name == "llm-mistral")' | \ + grep -q false || ( \ + echo "Test failed: llm-mistral should not have been loaded" && \ + exit 1 \ + ) +echo "$PLUGINS2" | jq 'any(.[]; .name == "llm-cluster")' | \ + grep -q true || ( \ + echo "Test llm-cluster should have been loaded" && \ + exit 1 \ + ) +# With LLM_LOAD_PLUGINS='' we should see no plugins +PLUGINS3=$(LLM_LOAD_PLUGINS='' llm plugins) +echo "$PLUGINS3"| \ + grep -q '\[\]' || ( \ + echo "Test failed: plugins should have returned []" && \ + exit 1 \ + ) diff --git a/tests/test_aliases.py b/tests/test_aliases.py new file mode 100644 index 0000000..661eb15 --- /dev/null +++ b/tests/test_aliases.py @@ -0,0 +1,128 @@ +from click.testing import CliRunner +from llm.cli import cli +import llm +import json +import pytest +import re + + +@pytest.mark.parametrize("model_id_or_alias", ("gpt-3.5-turbo", "chatgpt")) +def test_set_alias(model_id_or_alias): + with pytest.raises(llm.UnknownModelError): + llm.get_model("this-is-a-new-alias") + llm.set_alias("this-is-a-new-alias", model_id_or_alias) + assert llm.get_model("this-is-a-new-alias").model_id == "gpt-3.5-turbo" + + +def test_remove_alias(): + with pytest.raises(KeyError): + llm.remove_alias("some-other-alias") + llm.set_alias("some-other-alias", "gpt-3.5-turbo") + assert llm.get_model("some-other-alias").model_id == "gpt-3.5-turbo" + llm.remove_alias("some-other-alias") + with pytest.raises(llm.UnknownModelError): + llm.get_model("some-other-alias") + + +@pytest.mark.parametrize("args", (["aliases", "list"], ["aliases"])) +def test_cli_aliases_list(args): + llm.set_alias("e-demo", "embed-demo") + runner = CliRunner() + result = runner.invoke(cli, args) + assert result.exit_code == 0 + for line in ( + "3.5 : gpt-3.5-turbo\n" + "chatgpt : gpt-3.5-turbo\n" + "chatgpt-16k : gpt-3.5-turbo-16k\n" + "3.5-16k : gpt-3.5-turbo-16k\n" + "4 : gpt-4\n" + "gpt4 : gpt-4\n" + "4-32k : gpt-4-32k\n" + "e-demo : embed-demo (embedding)\n" + "ada : text-embedding-ada-002 (embedding)\n" + ).split("\n"): + line = line.strip() + if not line: + continue + # Turn the whitespace into a regex + regex = r"\s+".join(re.escape(part) for part in line.split()) + assert re.search(regex, result.output) + + +@pytest.mark.parametrize("args", (["aliases", "list"], ["aliases"])) +def test_cli_aliases_list_json(args): + llm.set_alias("e-demo", "embed-demo") + runner = CliRunner() + result = runner.invoke(cli, args + ["--json"]) + assert result.exit_code == 0 + assert ( + json.loads(result.output).items() + >= { + "3.5": "gpt-3.5-turbo", + "chatgpt": "gpt-3.5-turbo", + "chatgpt-16k": "gpt-3.5-turbo-16k", + "3.5-16k": "gpt-3.5-turbo-16k", + "4": "gpt-4", + "gpt4": "gpt-4", + "4-32k": "gpt-4-32k", + "ada": "text-embedding-ada-002", + "e-demo": "embed-demo", + }.items() + ) + + +@pytest.mark.parametrize( + "args,expected,expected_error", + ( + (["foo", "bar"], {"foo": "bar"}, None), + (["foo", "-q", "mo"], {"foo": "mock"}, None), + (["foo", "-q", "mog"], None, "No model found matching query: mog"), + ), +) +def test_cli_aliases_set(user_path, args, expected, expected_error): + # Should be not aliases.json at start + assert not (user_path / "aliases.json").exists() + runner = CliRunner() + result = runner.invoke(cli, ["aliases", "set"] + args) + if not expected_error: + assert result.exit_code == 0 + assert (user_path / "aliases.json").exists() + assert json.loads((user_path / "aliases.json").read_text("utf-8")) == expected + else: + assert result.exit_code == 1 + assert result.output.strip() == f"Error: {expected_error}" + + +def test_cli_aliases_path(user_path): + runner = CliRunner() + result = runner.invoke(cli, ["aliases", "path"]) + assert result.exit_code == 0 + assert result.output.strip() == str(user_path / "aliases.json") + + +def test_cli_aliases_remove(user_path): + (user_path / "aliases.json").write_text(json.dumps({"foo": "bar"}), "utf-8") + runner = CliRunner() + result = runner.invoke(cli, ["aliases", "remove", "foo"]) + assert result.exit_code == 0 + assert json.loads((user_path / "aliases.json").read_text("utf-8")) == {} + + +def test_cli_aliases_remove_invalid(user_path): + (user_path / "aliases.json").write_text(json.dumps({"foo": "bar"}), "utf-8") + runner = CliRunner() + result = runner.invoke(cli, ["aliases", "remove", "invalid"]) + assert result.exit_code == 1 + assert result.output == "Error: No such alias: invalid\n" + + +@pytest.mark.parametrize("args", (["models"], ["models", "list"])) +def test_cli_aliases_are_registered(user_path, args): + (user_path / "aliases.json").write_text( + json.dumps({"foo": "bar", "turbo": "gpt-3.5-turbo"}), "utf-8" + ) + runner = CliRunner() + result = runner.invoke(cli, args) + assert result.exit_code == 0 + # Check for model line only, without keys, as --options is not used + assert "gpt-3.5-turbo (aliases: 3.5, chatgpt, turbo)" in result.output diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 0000000..30e36e3 --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,61 @@ +import llm +import pytest + + +@pytest.mark.asyncio +async def test_async_model(async_mock_model): + gathered = [] + async_mock_model.enqueue(["hello world"]) + async for chunk in async_mock_model.prompt("hello"): + gathered.append(chunk) + assert gathered == ["hello world"] + # Not as an iterator + async_mock_model.enqueue(["hello world"]) + response = await async_mock_model.prompt("hello") + text = await response.text() + assert text == "hello world" + assert isinstance(response, llm.AsyncResponse) + usage = await response.usage() + assert usage.input == 1 + assert usage.output == 1 + assert usage.details is None + + +@pytest.mark.asyncio +async def test_async_model_conversation(async_mock_model): + async_mock_model.enqueue(["joke 1"]) + conversation = async_mock_model.conversation() + response = await conversation.prompt("joke") + text = await response.text() + assert text == "joke 1" + async_mock_model.enqueue(["joke 2"]) + response2 = await conversation.prompt("again") + text2 = await response2.text() + assert text2 == "joke 2" + + +@pytest.mark.asyncio +async def test_async_on_done(async_mock_model): + async_mock_model.enqueue(["hello world"]) + response = await async_mock_model.prompt(prompt="hello") + caught = [] + + def done(response): + caught.append(response) + + assert len(caught) == 0 + await response.on_done(done) + await response.text() + assert response._done + assert len(caught) == 1 + + +@pytest.mark.asyncio +async def test_async_conversation(async_mock_model): + async_mock_model.enqueue(["one"]) + conversation = async_mock_model.conversation() + response1 = await conversation.prompt("hi").text() + async_mock_model.enqueue(["two"]) + response2 = await conversation.prompt("hi").text() + assert response1 == "one" + assert response2 == "two" diff --git a/tests/test_async_parity.py b/tests/test_async_parity.py new file mode 100644 index 0000000..b20fb6e --- /dev/null +++ b/tests/test_async_parity.py @@ -0,0 +1,379 @@ +"""Async parity: every sync API must work the same +way on AsyncResponse and AsyncConversation. + +Uses the llm-echo plugin (sync ``Echo`` + async ``EchoAsync``) so both +paths exercise real registered models with identical behaviour. +""" + +import json + +import llm +import pytest + +# ---- basic sanity: both variants are registered -------------------- + + +def test_echo_registered_for_both(): + assert isinstance(llm.get_model("echo"), llm.Model) + assert isinstance(llm.get_async_model("echo"), llm.AsyncModel) + + +# ---- AsyncResponse.to_dict / from_dict ----------------------------- + + +@pytest.mark.asyncio +async def test_async_to_dict_captures_chain_and_output(): + model = llm.get_async_model("echo") + r = model.prompt("hello") + await r.text() + + d = r.to_dict() + assert d["model"] == "echo" + assert d["prompt"]["messages"] == [llm.user("hello").to_dict()] + # Echo's output is JSON describing the input; it's the assistant's text. + assert len(d["messages"]) == 1 + assert d["messages"][0]["role"] == "assistant" + + +@pytest.mark.asyncio +async def test_async_to_dict_raises_before_awaited(): + model = llm.get_async_model("echo") + r = model.prompt("hello") + with pytest.raises(ValueError): + r.to_dict() + + +@pytest.mark.asyncio +async def test_async_from_dict_rehydrates(): + model = llm.get_async_model("echo") + r = model.prompt("hello") + await r.text() + + payload = json.dumps(r.to_dict()) + restored = llm.AsyncResponse.from_dict(json.loads(payload)) + + assert restored._done + # text_or_raise should match (same text as original) + assert restored.text_or_raise() == r.text_or_raise() + # messages structure preserved + assert await restored.messages() == await r.messages() + # prompt.messages (the chain that was sent) preserved + assert restored.prompt.messages == r.prompt.messages + + +@pytest.mark.asyncio +async def test_async_from_dict_then_reply_continues(): + """Persist an async response across process + boundary (via JSON), rehydrate, continue with reply().""" + model = llm.get_async_model("echo") + r1 = model.prompt("q1") + await r1.text() + + payload = json.dumps(r1.to_dict()) + restored = llm.AsyncResponse.from_dict(json.loads(payload)) + + r2 = await restored.reply("q2") + await r2.text() + + # r2 was sent the full chain including r1's output. + chain_roles = [m.role for m in r2.prompt.messages] + assert chain_roles == ["user", "assistant", "user"] + assert r2.prompt.messages[0].parts[0].text == "q1" + assert r2.prompt.messages[-1].parts[0].text == "q2" + + +# ---- AsyncResponse rehydrated via from_row (SQLite path) ----------- + + +@pytest.mark.asyncio +async def test_async_from_row_response_messages_synthesized(tmp_path): + """SQLite rehydrate for async responses must populate + response.messages from _chunks+_tool_calls so follow-up chains + don't silently drop the assistant turn.""" + import sqlite_utils + from llm.migrations import migrate + + model = llm.get_async_model("echo") + r = model.prompt("hello") + await r.text() + + db = sqlite_utils.Database(str(tmp_path / "logs.db")) + migrate(db) + # to_sync_response is what log_to_db uses for async. + sync_r = await r.to_sync_response() + sync_r.log_to_db(db) + + row = next(db["responses"].rows) + rehydrated = llm.AsyncResponse.from_row(db, row) + + assert rehydrated._stream_events == [] + # response.messages falls back to _chunks — must not be empty. + msgs = await rehydrated.messages() + assert len(msgs) == 1 + assert msgs[0].role == "assistant" + assert isinstance(msgs[0].parts[0], llm.parts.TextPart) + + +# ---- AsyncConversation follow-up via load_conversation ------------- + + +@pytest.mark.asyncio +async def test_async_load_conversation_follow_up_preserves_chain(tmp_path): + """Async equivalent of the llm -c regression: after log_to_db + + load_conversation, a follow-up turn's prompt.messages is the full + [user, assistant, user] chain — not missing the assistant.""" + import sqlite_utils + from llm.cli import load_conversation + from llm.migrations import migrate + + model = llm.get_async_model("echo") + r1 = model.prompt("q1") + await r1.text() + + db_path = tmp_path / "logs.db" + db = sqlite_utils.Database(str(db_path)) + migrate(db) + (await r1.to_sync_response()).log_to_db(db) + + conv = load_conversation(None, async_=True, database=str(db_path)) + r2 = conv.prompt("q2") + await r2.text() + + chain = r2.prompt.messages + assert [m.role for m in chain] == ["user", "assistant", "user"] + assert chain[0].parts[0].text == "q1" + assert chain[-1].parts[0].text == "q2" + + +# ---- Sync/async semantic parity for reply()+to_dict() -------------- + + +def _capture_sync(model): + r1 = model.prompt("ping") + r1.text() + payload1 = json.dumps(r1.to_dict()) + restored = llm.Response.from_dict(json.loads(payload1)) + r2 = restored.reply("pong") + r2.text() + return r2.prompt.messages + + +async def _capture_async(model): + r1 = model.prompt("ping") + await r1.text() + payload1 = json.dumps(r1.to_dict()) + restored = llm.AsyncResponse.from_dict(json.loads(payload1)) + r2 = await restored.reply("pong") + await r2.text() + return r2.prompt.messages + + +@pytest.mark.asyncio +async def test_sync_and_async_produce_identical_chain(): + """Run the full save → restore → reply loop against sync Echo and + async EchoAsync. The chain sent on the second turn must be + structurally identical.""" + sync_chain = _capture_sync(llm.get_model("echo")) + async_chain = await _capture_async(llm.get_async_model("echo")) + + # Echo's assistant output differs between invocations only in + # the "previous" field — but for the first turn both see empty + # previous, so outputs match. + sync_dicts = [m.to_dict() for m in sync_chain] + async_dicts = [m.to_dict() for m in async_chain] + assert sync_dicts == async_dicts + + +# ---- AsyncChainResponse tool-result turn pre-bakes chain ----------- + + +@pytest.mark.asyncio +async def test_async_chain_tool_result_turn_has_full_chain(): + """AsyncChainResponse must pre-bake the full chain on tool-result + turns, same as sync ChainResponse.""" + + async def my_tool(x: int) -> int: + "Double the input." + return x * 2 + + model = llm.get_async_model("echo") + # Drive a one-iteration chain by asking echo to emit a tool call + # (echo's JSON-prompt syntax). + chain = model.chain( + json.dumps( + { + "tool_calls": [{"name": "my_tool", "arguments": {"x": 5}}], + "prompt": "prompt", + } + ), + tools=[llm.Tool.function(my_tool, name="my_tool")], + ) + + responses = [] + async for response in chain.responses(): + responses.append(response) + + # Two responses: the tool-call turn and the tool-result turn. + assert len(responses) == 2 + second = responses[1] + # Second turn's prompt.messages includes the prior turn (user + + # assistant with tool call) plus a tool-role message with the result. + chain_roles = [m.role for m in second.prompt.messages] + assert "tool" in chain_roles + assert chain_roles[0] == "user" + + +# ---- astream_events() parity with stream_events() ------------------ + + +@pytest.mark.asyncio +async def test_astream_events_matches_stream_events_for_text_only(): + """Echo yields plain str (legacy plugin). Both sync and async + paths should wrap those into StreamEvent(type='text') with the + same shape.""" + sync_model = llm.get_model("echo") + async_model = llm.get_async_model("echo") + + sync_r = sync_model.prompt("hello") + sync_events = list(sync_r.stream_events()) + + async_r = async_model.prompt("hello") + async_events = [] + async for ev in async_r.astream_events(): + async_events.append(ev) + + # Same event types, same payload. + assert [e.type for e in sync_events] == [e.type for e in async_events] + assert all(e.type == "text" for e in sync_events) + assert "".join(e.chunk for e in sync_events) == "".join( + e.chunk for e in async_events + ) + + +# ---- Async reply chaining -------------------------------------------- + + +# ---- Additional edge cases ---------------------------------------- + + +@pytest.mark.asyncio +async def test_async_from_dict_model_override(): + model = llm.get_async_model("echo") + r = model.prompt("hi") + await r.text() + payload = json.dumps(r.to_dict()) + + # Pass model explicitly to override whatever's in the payload. + alt = llm.get_async_model("echo") + restored = llm.AsyncResponse.from_dict(json.loads(payload), model=alt) + assert restored.model is alt + + +def test_sync_from_dict_model_override(): + model = llm.get_model("echo") + r = model.prompt("hi") + r.text() + payload = json.dumps(r.to_dict()) + + alt = llm.get_model("echo") + restored = llm.Response.from_dict(json.loads(payload), model=alt) + assert restored.model is alt + + +@pytest.mark.asyncio +async def test_async_to_dict_preserves_datetime(): + model = llm.get_async_model("echo") + r = model.prompt("hi") + await r.text() + d = r.to_dict() + assert "datetime_utc" in d + assert isinstance(d["datetime_utc"], str) + + +@pytest.mark.asyncio +async def test_async_to_dict_preserves_usage_when_set(async_mock_model): + """When a plugin calls response.set_usage, to_dict captures it. + async_mock_model does set usage; llm-echo's async variant doesn't.""" + async_mock_model.enqueue(["ok"]) + r = async_mock_model.prompt("hi") + await r.text() + d = r.to_dict() + assert "usage" in d + assert d["usage"]["input"] is not None + assert d["usage"]["output"] is not None + + # And it round-trips. + restored = llm.AsyncResponse.from_dict(d, model=async_mock_model) + assert restored.input_tokens == d["usage"]["input"] + assert restored.output_tokens == d["usage"]["output"] + + +@pytest.mark.asyncio +async def test_async_reply_messages_kwarg_appends(): + """AsyncResponse.reply(messages=[...]) appends extra messages onto + the chain in place of a trailing user string (mirrors sync test).""" + model = llm.get_async_model("echo") + r1 = model.prompt("q1") + await r1.text() + r2 = await r1.reply(messages=[llm.user("extra")]) + await r2.text() + assert [m.role for m in r2.prompt.messages] == ["user", "assistant", "user"] + assert r2.prompt.messages[-1].parts[0].text == "extra" + + +@pytest.mark.asyncio +async def test_async_full_chain_to_dict_round_trip_three_turns(): + """Serialize on turn 3 — chain must include q1, a1, q2, a2, q3 on + round-trip.""" + model = llm.get_async_model("echo") + r1 = model.prompt("q1") + await r1.text() + r2 = await r1.reply("q2") + await r2.text() + r3 = await r2.reply("q3") + await r3.text() + + payload = json.dumps(r3.to_dict()) + restored = llm.AsyncResponse.from_dict(json.loads(payload)) + assert [m.role for m in restored.prompt.messages] == [ + "user", + "assistant", + "user", + "assistant", + "user", + ] + texts = [m.parts[0].text for m in restored.prompt.messages if m.parts] + assert texts[0] == "q1" + assert texts[2] == "q2" + assert texts[4] == "q3" + + # And continuing from the restored response extends the chain. + r4 = await restored.reply("q4") + await r4.text() + assert [m.role for m in r4.prompt.messages] == [ + "user", + "assistant", + "user", + "assistant", + "user", + "assistant", + "user", + ] + + +@pytest.mark.asyncio +async def test_async_reply_chains_three_turns(): + model = llm.get_async_model("echo") + r1 = model.prompt("q1") + await r1.text() + r2 = await r1.reply("q2") + await r2.text() + r3 = await r2.reply("q3") + await r3.text() + + chain = r3.prompt.messages + assert [m.role for m in chain] == ["user", "assistant", "user", "assistant", "user"] + texts = [m.parts[0].text for m in chain if m.parts] + assert texts[0] == "q1" + assert texts[2] == "q2" + assert texts[4] == "q3" diff --git a/tests/test_attachments.py b/tests/test_attachments.py new file mode 100644 index 0000000..6e20dd7 --- /dev/null +++ b/tests/test_attachments.py @@ -0,0 +1,98 @@ +from click.testing import CliRunner +import os +import sys +from unittest.mock import ANY +import llm +from llm import cli +import pytest + +TINY_PNG = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\xa6\x00\x00\x01\x1a" + b"\x02\x03\x00\x00\x00\xe6\x99\xc4^\x00\x00\x00\tPLTE\xff\xff\xff" + b"\x00\xff\x00\xfe\x01\x00\x12t\x01J\x00\x00\x00GIDATx\xda\xed\xd81\x11" + b"\x000\x08\xc0\xc0.]\xea\xaf&Q\x89\x04V\xe0>\xf3+\xc8\x91Z\xf4\xa2\x08EQ\x14E" + b"Q\x14EQ\x14EQ\xd4B\x91$I3\xbb\xbf\x08EQ\x14EQ\x14EQ\x14E\xd1\xa5" + b"\xd4\x17\x91\xc6\x95\x05\x15\x0f\x9f\xc5\t\x9f\xa4\x00\x00\x00\x00IEND\xaeB`" + b"\x82" +) + +TINY_WAV = b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00D\xac\x00\x00" + + +@pytest.mark.parametrize( + "attachment_type,attachment_content", + [ + ("image/png", TINY_PNG), + ("audio/wav", TINY_WAV), + ], +) +def test_prompt_attachment(mock_model, logs_db, attachment_type, attachment_content): + runner = CliRunner() + mock_model.enqueue(["two boxes"]) + result = runner.invoke( + cli.cli, + ["prompt", "-m", "mock", "describe file", "-a", "-"], + input=attachment_content, + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + assert result.output == "two boxes\n" + assert mock_model.history[0][0].attachments[0] == llm.Attachment( + type=attachment_type, path=None, url=None, content=attachment_content, _id=ANY + ) + + # Check it was logged correctly + conversations = list(logs_db["conversations"].rows) + assert len(conversations) == 1 + conversation = conversations[0] + assert conversation["model"] == "mock" + assert conversation["name"] == "describe file" + response = list(logs_db["responses"].rows)[0] + attachment = list(logs_db["attachments"].rows)[0] + assert attachment == { + "id": ANY, + "type": attachment_type, + "path": None, + "url": None, + "content": attachment_content, + } + prompt_attachment = list(logs_db["prompt_attachments"].rows)[0] + assert prompt_attachment["attachment_id"] == attachment["id"] + assert prompt_attachment["response_id"] == response["id"] + + +def _count_open_fds(): + """Count open file descriptors (macOS and Linux only).""" + if sys.platform == "darwin": + fd_dir = "/dev/fd" + elif sys.platform == "linux": + fd_dir = "/proc/self/fd" + else: + return None + return len(os.listdir(fd_dir)) + + +@pytest.mark.skipif( + sys.platform not in ("darwin", "linux"), + reason="File descriptor counting only supported on macOS and Linux", +) +def test_attachment_no_file_descriptor_leak(tmp_path): + """Verify reading attachments from paths doesn't leak file descriptors.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"x" * 1000) + + # Warm up - first call may open other resources + attachment = llm.Attachment(path=str(test_file)) + _ = attachment.id() + _ = attachment.content_bytes() + + baseline = _count_open_fds() + + # Create many attachments and read them + for _ in range(100): + a = llm.Attachment(path=str(test_file)) + _ = a.id() + _ = a.content_bytes() + + # File descriptor count should not have grown significantly + assert _count_open_fds() <= baseline + 5 diff --git a/tests/test_chat.py b/tests/test_chat.py new file mode 100644 index 0000000..dbd77ab --- /dev/null +++ b/tests/test_chat.py @@ -0,0 +1,396 @@ +from click.testing import CliRunner +import re +from unittest.mock import ANY +import json +import llm.cli +import pytest +import sqlite_utils +import sys +import textwrap + + +@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") +def test_chat_basic(mock_model, logs_db): + runner = CliRunner() + mock_model.enqueue(["one world"]) + mock_model.enqueue(["one again"]) + result = runner.invoke( + llm.cli.cli, + ["chat", "-m", "mock"], + input="Hi\nHi two\nquit\n", + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert result.output == ( + "Chatting with mock" + "\nType 'exit' or 'quit' to exit" + "\nType '!multi' to enter multiple lines, then '!end' to finish" + "\nType '!edit' to open your default editor and modify the prompt" + "\nType '!fragment [ ...]' to insert one or more fragments" + "\n> Hi" + "\none world" + "\n> Hi two" + "\none again" + "\n> quit" + "\n" + ) + # Should have logged + conversations = list(logs_db["conversations"].rows) + assert conversations[0] == { + "id": ANY, + "name": "Hi", + "model": "mock", + } + conversation_id = conversations[0]["id"] + responses = list(logs_db["responses"].rows) + assert responses == [ + { + "id": ANY, + "model": "mock", + "resolved_model": None, + "prompt": "Hi", + "system": None, + "prompt_json": None, + "options_json": "{}", + "response": "one world", + "response_json": None, + "conversation_id": conversation_id, + "duration_ms": ANY, + "datetime_utc": ANY, + "input_tokens": 1, + "output_tokens": 1, + "token_details": None, + "schema_id": None, + "reasoning": None, + }, + { + "id": ANY, + "model": "mock", + "resolved_model": None, + "prompt": "Hi two", + "system": None, + "prompt_json": None, + "options_json": "{}", + "response": "one again", + "response_json": None, + "conversation_id": conversation_id, + "duration_ms": ANY, + "datetime_utc": ANY, + "input_tokens": 2, + "output_tokens": 1, + "token_details": None, + "schema_id": None, + "reasoning": None, + }, + ] + # Now continue that conversation + mock_model.enqueue(["continued"]) + result2 = runner.invoke( + llm.cli.cli, + ["chat", "-m", "mock", "-c"], + input="Continue\nquit\n", + catch_exceptions=False, + ) + assert result2.exit_code == 0 + assert result2.output == ( + "Chatting with mock" + "\nType 'exit' or 'quit' to exit" + "\nType '!multi' to enter multiple lines, then '!end' to finish" + "\nType '!edit' to open your default editor and modify the prompt" + "\nType '!fragment [ ...]' to insert one or more fragments" + "\n> Continue" + "\ncontinued" + "\n> quit" + "\n" + ) + new_responses = list( + logs_db.query( + "select * from responses where id not in ({})".format( + ", ".join("?" for _ in responses) + ), + [r["id"] for r in responses], + ) + ) + assert new_responses == [ + { + "id": ANY, + "model": "mock", + "resolved_model": None, + "prompt": "Continue", + "system": None, + "prompt_json": None, + "options_json": "{}", + "response": "continued", + "response_json": None, + "conversation_id": conversation_id, + "duration_ms": ANY, + "datetime_utc": ANY, + "input_tokens": 1, + "output_tokens": 1, + "token_details": None, + "schema_id": None, + "reasoning": None, + } + ] + + +@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") +def test_chat_system(mock_model, logs_db): + runner = CliRunner() + mock_model.enqueue(["I am mean"]) + result = runner.invoke( + llm.cli.cli, + ["chat", "-m", "mock", "--system", "You are mean"], + input="Hi\nquit\n", + ) + assert result.exit_code == 0 + assert result.output == ( + "Chatting with mock" + "\nType 'exit' or 'quit' to exit" + "\nType '!multi' to enter multiple lines, then '!end' to finish" + "\nType '!edit' to open your default editor and modify the prompt" + "\nType '!fragment [ ...]' to insert one or more fragments" + "\n> Hi" + "\nI am mean" + "\n> quit" + "\n" + ) + responses = list(logs_db["responses"].rows) + assert responses == [ + { + "id": ANY, + "model": "mock", + "resolved_model": None, + "prompt": "Hi", + "system": "You are mean", + "prompt_json": None, + "options_json": "{}", + "response": "I am mean", + "response_json": None, + "conversation_id": ANY, + "duration_ms": ANY, + "datetime_utc": ANY, + "input_tokens": 1, + "output_tokens": 1, + "token_details": None, + "schema_id": None, + "reasoning": None, + } + ] + + +@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") +def test_chat_options(mock_model, logs_db, user_path): + options_path = user_path / "model_options.json" + options_path.write_text(json.dumps({"mock": {"max_tokens": "5"}}), "utf-8") + + runner = CliRunner() + mock_model.enqueue(["Default options response"]) + result = runner.invoke( + llm.cli.cli, + ["chat", "-m", "mock"], + input="Hi\nquit\n", + ) + assert result.exit_code == 0 + mock_model.enqueue(["Override options response"]) + result = runner.invoke( + llm.cli.cli, + ["chat", "-m", "mock", "--option", "max_tokens", "10"], + input="Hi with override\nquit\n", + ) + assert result.exit_code == 0 + responses = list(logs_db["responses"].rows) + assert responses == [ + { + "id": ANY, + "model": "mock", + "resolved_model": None, + "prompt": "Hi", + "system": None, + "prompt_json": None, + "options_json": '{"max_tokens": 5}', + "response": "Default options response", + "response_json": None, + "conversation_id": ANY, + "duration_ms": ANY, + "datetime_utc": ANY, + "input_tokens": 1, + "output_tokens": 1, + "token_details": None, + "schema_id": None, + "reasoning": None, + }, + { + "id": ANY, + "model": "mock", + "resolved_model": None, + "prompt": "Hi with override", + "system": None, + "prompt_json": None, + "options_json": '{"max_tokens": 10}', + "response": "Override options response", + "response_json": None, + "conversation_id": ANY, + "duration_ms": ANY, + "datetime_utc": ANY, + "input_tokens": 3, + "output_tokens": 1, + "token_details": None, + "schema_id": None, + "reasoning": None, + }, + ] + + +@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") +@pytest.mark.parametrize( + "input,expected", + ( + ( + "Hi\n!multi\nthis is multiple lines\nuntil the !end\n!end\nquit\n", + [ + {"prompt": "Hi", "response": "One\n"}, + { + "prompt": "this is multiple lines\nuntil the !end", + "response": "Two\n", + }, + ], + ), + # quit should not work within !multi + ( + "!multi\nthis is multiple lines\nquit\nuntil the !end\n!end\nquit\n", + [ + { + "prompt": "this is multiple lines\nquit\nuntil the !end", + "response": "One\n", + } + ], + ), + # Try custom delimiter + ( + "!multi abc\nCustom delimiter\n!end\n!end 123\n!end abc\nquit\n", + [{"prompt": "Custom delimiter\n!end\n!end 123", "response": "One\n"}], + ), + ), +) +def test_chat_multi(mock_model, logs_db, input, expected): + runner = CliRunner() + mock_model.enqueue(["One\n"]) + mock_model.enqueue(["Two\n"]) + mock_model.enqueue(["Three\n"]) + result = runner.invoke( + llm.cli.cli, ["chat", "-m", "mock", "--option", "max_tokens", "10"], input=input + ) + assert result.exit_code == 0 + rows = list(logs_db["responses"].rows_where(select="prompt, response")) + assert rows == expected + + +@pytest.mark.parametrize("custom_database_path", (False, True)) +def test_llm_chat_creates_log_database(tmpdir, monkeypatch, custom_database_path): + user_path = tmpdir / "user" + custom_db_path = tmpdir / "custom_log.db" + monkeypatch.setenv("LLM_USER_PATH", str(user_path)) + runner = CliRunner() + args = ["chat", "-m", "mock"] + if custom_database_path: + args.extend(["--database", str(custom_db_path)]) + result = runner.invoke( + llm.cli.cli, + args, + catch_exceptions=False, + input="Hi\nHi two\nquit\n", + ) + assert result.exit_code == 0 + # Should have created user_path and put a logs.db in it + if custom_database_path: + assert custom_db_path.exists() + db_path = str(custom_db_path) + else: + assert (user_path / "logs.db").exists() + db_path = str(user_path / "logs.db") + assert sqlite_utils.Database(db_path)["responses"].count == 2 + + +@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") +def test_chat_tools(logs_db): + runner = CliRunner() + functions = textwrap.dedent(""" + def upper(text: str) -> str: + "Convert text to upper case" + return text.upper() + """) + result = runner.invoke( + llm.cli.cli, + ["chat", "-m", "echo", "--functions", functions], + input="\n".join( + [ + json.dumps( + { + "prompt": "Convert hello to uppercase", + "tool_calls": [ + {"name": "upper", "arguments": {"text": "hello"}} + ], + } + ), + "quit", + ] + ), + catch_exceptions=False, + ) + assert result.exit_code == 0 + normalized_output = re.sub(r"tc_[0-9a-z]{26}", "tc_TCID", result.output) + assert normalized_output == ( + "Chatting with echo\n" + "Type 'exit' or 'quit' to exit\n" + "Type '!multi' to enter multiple lines, then '!end' to finish\n" + "Type '!edit' to open your default editor and modify the prompt\n" + "Type '!fragment [ ...]' to insert one or more fragments\n" + '> {"prompt": "Convert hello to uppercase", "tool_calls": [{"name": "upper", ' + '"arguments": {"text": "hello"}}]}\n' + "{\n" + ' "prompt": "Convert hello to uppercase",\n' + ' "system": "",\n' + ' "attachments": [],\n' + ' "stream": true,\n' + ' "previous": []\n' + "}{\n" + ' "prompt": "",\n' + ' "system": "",\n' + ' "attachments": [],\n' + ' "stream": true,\n' + ' "previous": [\n' + " {\n" + ' "prompt": "{\\"prompt\\": \\"Convert hello to uppercase\\", ' + '\\"tool_calls\\": [{\\"name\\": \\"upper\\", \\"arguments\\": {\\"text\\": ' + '\\"hello\\"}}]}"\n' + " }\n" + " ],\n" + ' "tool_results": [\n' + " {\n" + ' "name": "upper",\n' + ' "output": "HELLO",\n' + ' "tool_call_id": "tc_TCID"\n' + " }\n" + " ]\n" + "}\n" + "> quit\n" + ) + + +@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") +def test_chat_fragments(tmpdir): + path1 = str(tmpdir / "frag1.txt") + path2 = str(tmpdir / "frag2.txt") + with open(path1, "w") as fp: + fp.write("one") + with open(path2, "w") as fp: + fp.write("two") + runner = CliRunner() + output = runner.invoke( + llm.cli.cli, + ["chat", "-m", "echo", "-f", path1], + input=("hi\n!fragment {}\nquit\n".format(path2)), + ).output + assert '"prompt": "one' in output + assert '"prompt": "two"' in output diff --git a/tests/test_chat_templates.py b/tests/test_chat_templates.py new file mode 100644 index 0000000..7687d78 --- /dev/null +++ b/tests/test_chat_templates.py @@ -0,0 +1,100 @@ +from click.testing import CliRunner +import sys +import llm.cli +import pytest + + +@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") +def test_chat_template_system_only_no_duplicate_prompt( + mock_model, logs_db, templates_path +): + # Template that only sets a system prompt, no user prompt + (templates_path / "wild-french.yaml").write_text( + "system: Speak in French\n", "utf-8" + ) + + runner = CliRunner() + mock_model.enqueue(["Bonjour !"]) + result = runner.invoke( + llm.cli.cli, + ["chat", "-m", "mock", "-t", "wild-french"], + input="hi\nquit\n", + catch_exceptions=False, + ) + assert result.exit_code == 0 + + # Ensure the logged prompt is not duplicated (no "hi\nhi") + rows = list(logs_db["responses"].rows) + assert len(rows) == 1 + assert rows[0]["prompt"] == "hi" + assert rows[0]["system"] == "Speak in French" + + +@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") +def test_chat_system_fragments_only_first_turn(tmpdir, mock_model, logs_db): + # Create a system fragment file + sys_frag_path = str(tmpdir / "sys.txt") + with open(sys_frag_path, "w", encoding="utf-8") as fp: + fp.write("System fragment content") + + runner = CliRunner() + # Two responses queued for two turns + mock_model.enqueue(["first"]) + mock_model.enqueue(["second"]) + result = runner.invoke( + llm.cli.cli, + ["chat", "-m", "mock", "--system-fragment", sys_frag_path], + input="Hi\nHi two\nquit\n", + catch_exceptions=False, + ) + assert result.exit_code == 0 + + # Verify only the first response has the system fragment + responses = list(logs_db["responses"].rows) + assert len(responses) == 2 + first_id = responses[0]["id"] + second_id = responses[1]["id"] + + sys_frags = list(logs_db["system_fragments"].rows) + # Exactly one system fragment row, attached to the first response only + assert len(sys_frags) == 1 + assert sys_frags[0]["response_id"] == first_id + assert sys_frags[0]["response_id"] != second_id + + +@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") +def test_chat_template_loads_tools_into_logs(logs_db, templates_path): + # Template that specifies tools; ensure chat picks them up + (templates_path / "mytools.yaml").write_text( + "model: echo\n" "tools:\n" "- llm_version\n" "- llm_time\n", + "utf-8", + ) + + runner = CliRunner() + result = runner.invoke( + llm.cli.cli, + ["chat", "-t", "mytools"], + input="hi\nquit\n", + catch_exceptions=False, + ) + assert result.exit_code == 0 + + # Verify a single response was logged for the conversation + responses = list(logs_db["responses"].rows) + assert len(responses) == 1 + assert responses[0]["prompt"] == "hi" + response_id = responses[0]["id"] + + # Tools from the template should be recorded against that response + rows = list( + logs_db.query( + """ + select tools.name from tools + join tool_responses tr on tr.tool_id = tools.id + where tr.response_id = ? + order by tools.name + """, + [response_id], + ) + ) + assert [r["name"] for r in rows] == ["llm_time", "llm_version"] diff --git a/tests/test_cli_openai_models.py b/tests/test_cli_openai_models.py new file mode 100644 index 0000000..85d7531 --- /dev/null +++ b/tests/test_cli_openai_models.py @@ -0,0 +1,506 @@ +from click.testing import CliRunner +import json +import llm +from llm.cli import cli +import pytest +import sqlite_utils + + +@pytest.fixture +def mocked_models(httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://api.openai.com/v1/models", + json={ + "data": [ + { + "id": "ada:2020-05-03", + "object": "model", + "created": 1588537600, + "owned_by": "openai", + }, + { + "id": "babbage:2020-05-03", + "object": "model", + "created": 1588537600, + "owned_by": "openai", + }, + ] + }, + headers={"Content-Type": "application/json"}, + ) + return httpx_mock + + +def test_openai_models(mocked_models): + runner = CliRunner() + result = runner.invoke(cli, ["openai", "models", "--key", "x"]) + assert result.exit_code == 0 + assert result.output == ( + "id owned_by created \n" + "ada:2020-05-03 openai 2020-05-03T20:26:40+00:00\n" + "babbage:2020-05-03 openai 2020-05-03T20:26:40+00:00\n" + ) + + +def test_openai_options_min_max(): + options = { + "temperature": [0, 2], + "top_p": [0, 1], + "frequency_penalty": [-2, 2], + "presence_penalty": [-2, 2], + } + runner = CliRunner() + + for option, [min_val, max_val] in options.items(): + result = runner.invoke(cli, ["-m", "chatgpt", "-o", option, "-10"]) + assert result.exit_code == 1 + assert f"greater than or equal to {min_val}" in result.output + result2 = runner.invoke(cli, ["-m", "chatgpt", "-o", option, "10"]) + assert result2.exit_code == 1 + assert f"less than or equal to {max_val}" in result2.output + + +@pytest.mark.parametrize( + "model_id", + ( + "gpt-5", + "gpt-5-mini", + "gpt-5.1", + "gpt-5.2", + "gpt-5.4", + "gpt-5.5", + ), +) +def test_gpt5_models_support_verbosity_option(model_id): + assert "verbosity" in llm.get_model(model_id).Options.model_fields + assert "verbosity" in llm.get_async_model(model_id).Options.model_fields + + +@pytest.mark.parametrize("model_id", ("gpt-4o", "gpt-4.5-preview", "o3", "o4-mini")) +def test_non_gpt5_openai_chat_models_do_not_support_verbosity_option(model_id): + assert "verbosity" not in llm.get_model(model_id).Options.model_fields + assert "verbosity" not in llm.get_async_model(model_id).Options.model_fields + + +def test_gpt5_verbosity_option_is_sent_to_openai_chat_completions(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-5", + "usage": {}, + "choices": [{"message": {"content": "Verbose enough"}}], + }, + headers={"Content-Type": "application/json"}, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "-m", + "gpt-5", + "-o", + "chat_completions", + "1", + "-o", + "verbosity", + "high", + "--no-stream", + "--key", + "x", + "Say hi", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + request_body = json.loads(httpx_mock.get_requests()[-1].content) + assert request_body["verbosity"] == "high" + assert "text" not in request_body + + +def test_gpt5_verbosity_option_is_sent_to_openai_responses_by_default(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/responses", + json={ + "id": "resp_test_1", + "object": "response", + "created_at": 1, + "model": "gpt-5", + "output": [ + { + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "Verbose enough", + "annotations": [], + } + ], + } + ], + "usage": { + "input_tokens": 5, + "output_tokens": 3, + "total_tokens": 8, + }, + "status": "completed", + }, + headers={"Content-Type": "application/json"}, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "-m", + "gpt-5", + "-o", + "verbosity", + "high", + "--no-stream", + "--key", + "x", + "Say hi", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + request_body = json.loads(httpx_mock.get_requests()[-1].content) + assert request_body["text"]["verbosity"] == "high" + assert request_body["include"] == ["reasoning.encrypted_content"] + assert "verbosity" not in request_body + + +def test_gpt5_verbosity_option_validates_allowed_values(): + runner = CliRunner() + result = runner.invoke( + cli, + ["-m", "gpt-5", "-o", "verbosity", "extreme", "Say hi"], + ) + assert result.exit_code == 1 + assert "Input should be 'low', 'medium' or 'high'" in result.output + + +@pytest.mark.parametrize( + "model_id,expected_description", + ( + ( + "gpt-4o", + "Controls the detail level for image attachments. Supported values are low, high, and auto.", + ), + ( + "gpt-5.4", + "Controls the detail level for image attachments. Supported values are low, high, original, and auto.", + ), + ( + "gpt-5.5", + "Controls the detail level for image attachments. Supported values are low, high, original, and auto.", + ), + ), +) +def test_openai_image_detail_option_description(model_id, expected_description): + field = llm.get_model(model_id).Options.model_fields["image_detail"] + assert field.description == expected_description + + +def test_openai_image_detail_option_is_sent_on_image_attachments(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-4o", + "usage": {}, + "choices": [{"message": {"content": "Looks detailed"}}], + }, + headers={"Content-Type": "application/json"}, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "-m", + "gpt-4o", + "-o", + "image_detail", + "high", + "--at", + "https://example.com/image.jpg", + "image/jpeg", + "--no-stream", + "--key", + "x", + "Describe this", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + request_body = json.loads(httpx_mock.get_requests()[-1].content) + image_part = request_body["messages"][0]["content"][1] + assert image_part == { + "type": "image_url", + "image_url": { + "url": "https://example.com/image.jpg", + "detail": "high", + }, + } + assert "image_detail" not in request_body + + +def test_openai_image_detail_original_is_sent_for_gpt54(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-5.4", + "usage": {}, + "choices": [{"message": {"content": "Original detail"}}], + }, + headers={"Content-Type": "application/json"}, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "-m", + "gpt-5.4", + "-o", + "chat_completions", + "1", + "-o", + "image_detail", + "original", + "--at", + "https://example.com/image.jpg", + "image/jpeg", + "--no-stream", + "--key", + "x", + "Describe this", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + request_body = json.loads(httpx_mock.get_requests()[-1].content) + image_part = request_body["messages"][0]["content"][1] + assert image_part["image_url"]["detail"] == "original" + + +def test_openai_image_detail_original_is_sent_for_gpt54_responses_by_default( + httpx_mock, +): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/responses", + json={ + "id": "resp_test_1", + "object": "response", + "created_at": 1, + "model": "gpt-5.4", + "output": [ + { + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "Original detail", + "annotations": [], + } + ], + } + ], + "usage": { + "input_tokens": 5, + "output_tokens": 3, + "total_tokens": 8, + }, + "status": "completed", + }, + headers={"Content-Type": "application/json"}, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "-m", + "gpt-5.4", + "-o", + "image_detail", + "original", + "--at", + "https://example.com/image.jpg", + "image/jpeg", + "--no-stream", + "--key", + "x", + "Describe this", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + request_body = json.loads(httpx_mock.get_requests()[-1].content) + image_part = request_body["input"][0]["content"][1] + assert image_part == { + "type": "input_image", + "image_url": "https://example.com/image.jpg", + "detail": "original", + } + assert "image_detail" not in request_body + + +def test_openai_image_detail_original_is_rejected_for_other_models(): + runner = CliRunner() + result = runner.invoke( + cli, + ["-m", "gpt-5", "-o", "image_detail", "original", "Say hi"], + ) + assert result.exit_code == 1 + assert "Input should be 'low', 'high' or 'auto'" in result.output + + +@pytest.mark.parametrize("model", ("gpt-4o-mini", "gpt-4o-audio-preview")) +@pytest.mark.parametrize("filetype", ("mp3", "wav")) +def test_only_gpt4_audio_preview_allows_mp3_or_wav(httpx_mock, model, filetype): + httpx_mock.add_response( + method="HEAD", + url=f"https://www.example.com/example.{filetype}", + content=b"binary-data", + headers={"Content-Type": "audio/mpeg" if filetype == "mp3" else "audio/wav"}, + ) + if model == "gpt-4o-audio-preview": + httpx_mock.add_response( + method="POST", + # chat completion request + url="https://api.openai.com/v1/chat/completions", + json={ + "id": "chatcmpl-AQT9a30kxEaM1bqxRPepQsPlCyGJh", + "object": "chat.completion", + "created": 1730871958, + "model": "gpt-4o-audio-preview-2024-10-01", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Why did the pelican get kicked out of the restaurant?\n\nBecause he had a big bill and no way to pay it!", + "refusal": None, + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 55, + "completion_tokens": 25, + "total_tokens": 80, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 44, + "text_tokens": 11, + "image_tokens": 0, + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "text_tokens": 25, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0, + }, + }, + "system_fingerprint": "fp_49254d0e9b", + }, + headers={"Content-Type": "application/json"}, + ) + httpx_mock.add_response( + method="GET", + url=f"https://www.example.com/example.{filetype}", + content=b"binary-data", + headers={ + "Content-Type": "audio/mpeg" if filetype == "mp3" else "audio/wav" + }, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "-m", + model, + "-a", + f"https://www.example.com/example.{filetype}", + "--no-stream", + "--key", + "x", + ], + ) + if model == "gpt-4o-audio-preview": + assert result.exit_code == 0 + assert result.output == ( + "Why did the pelican get kicked out of the restaurant?\n\n" + "Because he had a big bill and no way to pay it!\n" + ) + else: + assert result.exit_code == 1 + long = "audio/mpeg" if filetype == "mp3" else "audio/wav" + assert ( + f"This model does not support attachments of type '{long}'" in result.output + ) + + +@pytest.mark.parametrize("async_", (False, True)) +@pytest.mark.parametrize("usage", (None, "-u", "--usage")) +def test_gpt4o_mini_sync_and_async(monkeypatch, tmpdir, httpx_mock, async_, usage): + user_path = tmpdir / "user_dir" + log_db = user_path / "logs.db" + monkeypatch.setenv("LLM_USER_PATH", str(user_path)) + assert not log_db.exists() + httpx_mock.add_response( + method="POST", + # chat completion request + url="https://api.openai.com/v1/chat/completions", + json={ + "id": "chatcmpl-AQT9a30kxEaM1bqxRPepQsPlCyGJh", + "object": "chat.completion", + "created": 1730871958, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Ho ho ho", + "refusal": None, + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1000, + "completion_tokens": 2000, + "total_tokens": 12, + }, + "system_fingerprint": "fp_49254d0e9b", + }, + headers={"Content-Type": "application/json"}, + ) + runner = CliRunner() + args = ["-m", "gpt-4o-mini", "--key", "x", "--no-stream"] + if usage: + args.append(usage) + if async_: + args.append("--async") + result = runner.invoke(cli, args, catch_exceptions=False) + assert result.exit_code == 0 + assert result.stdout == "Ho ho ho\n" + if usage: + assert result.stderr == "Token usage: 1,000 input, 2,000 output\n" + # Confirm it was correctly logged + assert log_db.exists() + db = sqlite_utils.Database(str(log_db)) + assert db["responses"].count == 1 + row = next(db["responses"].rows) + assert row["response"] == "Ho ho ho" diff --git a/tests/test_cli_options.py b/tests/test_cli_options.py new file mode 100644 index 0000000..ce8a704 --- /dev/null +++ b/tests/test_cli_options.py @@ -0,0 +1,138 @@ +from click.testing import CliRunner +from llm.cli import cli +import pytest +import json + + +@pytest.mark.parametrize( + "args,expected_options,expected_error", + ( + ( + ["gpt-4o-mini", "temperature", "0.5"], + {"gpt-4o-mini": {"temperature": "0.5"}}, + None, + ), + ( + ["gpt-4o-mini", "temperature", "invalid"], + {}, + "Error: temperature\n Input should be a valid number", + ), + ( + ["gpt-4o-mini", "not-an-option", "invalid"], + {}, + "Extra inputs are not permitted", + ), + ), +) +def test_set_model_default_options(user_path, args, expected_options, expected_error): + path = user_path / "model_options.json" + assert not path.exists() + runner = CliRunner() + result = runner.invoke(cli, ["models", "options", "set"] + args) + if not expected_error: + assert result.exit_code == 0 + assert path.exists() + data = json.loads(path.read_text("utf-8")) + assert data == expected_options + else: + assert result.exit_code == 1 + assert expected_error in result.output + + +def test_model_options_list_and_show(user_path): + (user_path / "model_options.json").write_text( + json.dumps( + {"gpt-4o-mini": {"temperature": 0.5}, "gpt-4o": {"temperature": 0.7}} + ), + "utf-8", + ) + runner = CliRunner() + result = runner.invoke(cli, ["models", "options", "list"]) + assert result.exit_code == 0 + assert ( + result.output + == "gpt-4o-mini:\n temperature: 0.5\ngpt-4o:\n temperature: 0.7\n" + ) + result = runner.invoke(cli, ["models", "options", "show", "gpt-4o-mini"]) + assert result.exit_code == 0 + assert result.output == "temperature: 0.5\n" + + +def test_model_options_clear(user_path): + path = user_path / "model_options.json" + path.write_text( + json.dumps( + { + "gpt-4o-mini": {"temperature": 0.5}, + "gpt-4o": {"temperature": 0.7, "top_p": 0.9}, + } + ), + "utf-8", + ) + assert path.exists() + runner = CliRunner() + # Clear all for gpt-4o-mini + result = runner.invoke(cli, ["models", "options", "clear", "gpt-4o-mini"]) + assert result.exit_code == 0 + # Clear just top_p for gpt-4o + result2 = runner.invoke(cli, ["models", "options", "clear", "gpt-4o", "top_p"]) + assert result2.exit_code == 0 + data = json.loads(path.read_text("utf-8")) + assert data == {"gpt-4o": {"temperature": 0.7}} + + +def test_prompt_uses_model_options(user_path): + path = user_path / "model_options.json" + path.write_text("{}", "utf-8") + # Prompt should not use an option + runner = CliRunner() + result = runner.invoke(cli, ["-m", "echo", "prompt"]) + assert result.exit_code == 0 + assert json.loads(result.output) == { + "prompt": "prompt", + "system": "", + "attachments": [], + "stream": True, + "previous": [], + } + + # Now set an option + path.write_text(json.dumps({"echo": {"example_bool": True}}), "utf-8") + + result2 = runner.invoke(cli, ["-m", "echo", "prompt"]) + assert result2.exit_code == 0 + assert json.loads(result2.output) == { + "prompt": "prompt", + "system": "", + "attachments": [], + "stream": True, + "previous": [], + "options": {"example_bool": True}, + } + + # Option can be over-ridden + result3 = runner.invoke( + cli, ["-m", "echo", "prompt", "-o", "example_bool", "false"] + ) + assert result3.exit_code == 0 + assert json.loads(result3.output) == { + "prompt": "prompt", + "system": "", + "attachments": [], + "stream": True, + "previous": [], + "options": {"example_bool": False}, + } + # Using an alias should also pick up that option + aliases_path = user_path / "aliases.json" + aliases_path.write_text('{"e": "echo"}', "utf-8") + result4 = runner.invoke(cli, ["-m", "e", "prompt"]) + assert result4.exit_code == 0 + assert json.loads(result4.output) == { + "prompt": "prompt", + "system": "", + "attachments": [], + "stream": True, + "previous": [], + "options": {"example_bool": True}, + } diff --git a/tests/test_cli_streaming.py b/tests/test_cli_streaming.py new file mode 100644 index 0000000..4414706 --- /dev/null +++ b/tests/test_cli_streaming.py @@ -0,0 +1,159 @@ +"""Tests for CLI streaming display: reasoning → stderr (dim), +text → stdout, -R / --hide-reasoning flag. +""" + +import click +from click.testing import CliRunner + +import llm +from llm.cli import cli + + +def test_text_goes_to_stdout_not_stderr(mock_model): + mock_model.enqueue(["Hello world"]) + runner = CliRunner() + result = runner.invoke( + cli, + ["-m", "mock", "hi", "--no-log"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert "Hello world" in result.stdout + # No reasoning was emitted — stderr should be empty. + assert result.stderr == "" + + +def test_reasoning_goes_to_stderr_not_stdout(mock_model): + mock_model.enqueue( + [ + llm.parts.StreamEvent( + type="reasoning", chunk="thinking hard", part_index=0 + ), + llm.parts.StreamEvent(type="text", chunk="answer", part_index=1), + ] + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["-m", "mock", "hi", "--no-log"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert "thinking hard" in result.stderr + assert "thinking hard" not in result.stdout + assert "answer" in result.stdout + + +def test_reasoning_rendered_in_dim_style(mock_model): + """The click.style(..., dim=True) wrapper emits the ANSI dim code.""" + mock_model.enqueue( + [ + llm.parts.StreamEvent(type="reasoning", chunk="t", part_index=0), + llm.parts.StreamEvent(type="text", chunk="x", part_index=1), + ] + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["-m", "mock", "hi", "--no-log"], + catch_exceptions=False, + color=True, + ) + assert result.exit_code == 0 + # ANSI dim escape sequence is \x1b[2m + dim_start = click.style("x", dim=True).split("x", 1)[0] + assert dim_start in result.stderr + + +def test_hide_reasoning_flag_suppresses_reasoning(mock_model): + mock_model.enqueue( + [ + llm.parts.StreamEvent( + type="reasoning", chunk="hidden thinking", part_index=0 + ), + llm.parts.StreamEvent(type="text", chunk="answer", part_index=1), + ] + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["-m", "mock", "hi", "--no-log", "--hide-reasoning"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert "hidden thinking" not in result.stderr + assert "hidden thinking" not in result.stdout + assert "answer" in result.stdout + assert mock_model.history[0][0].hide_reasoning is True + + +def test_hide_reasoning_short_flag_R(mock_model): + mock_model.enqueue( + [ + llm.parts.StreamEvent(type="reasoning", chunk="hidden", part_index=0), + llm.parts.StreamEvent(type="text", chunk="x", part_index=1), + ] + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["-m", "mock", "hi", "--no-log", "-R"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert "hidden" not in result.stderr + + +def test_newline_between_reasoning_and_text(mock_model): + """When reasoning ends and text begins, stderr gets a newline so the + text on stdout starts on a fresh visual line.""" + mock_model.enqueue( + [ + llm.parts.StreamEvent(type="reasoning", chunk="think", part_index=0), + llm.parts.StreamEvent(type="text", chunk="answer", part_index=1), + ] + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["-m", "mock", "hi", "--no-log"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + # Reasoning ends, then a newline is emitted on stderr. + assert result.stderr.rstrip("\n").endswith("think") or "think\n" in result.stderr + + +def test_async_path_reasoning_to_stderr(async_mock_model): + async_mock_model.enqueue( + [ + llm.parts.StreamEvent( + type="reasoning", chunk="async thinking", part_index=0 + ), + llm.parts.StreamEvent(type="text", chunk="async answer", part_index=1), + ] + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["-m", "mock", "hi", "--async", "--no-log"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert "async thinking" in result.stderr + assert "async answer" in result.stdout + + +def test_plain_str_plugin_still_works(mock_model): + """A plugin that yields plain strings (legacy) still displays + correctly — no reasoning branch, everything to stdout.""" + mock_model.enqueue(["plain ", "text"]) + runner = CliRunner() + result = runner.invoke( + cli, + ["-m", "mock", "hi", "--no-log"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert "plain text" in result.stdout + assert result.stderr == "" diff --git a/tests/test_embed.py b/tests/test_embed.py new file mode 100644 index 0000000..9b9c809 --- /dev/null +++ b/tests/test_embed.py @@ -0,0 +1,187 @@ +import json +import llm +from llm.embeddings import Entry +import pytest +import sqlite_utils +from unittest.mock import ANY + + +def test_demo_plugin(): + model = llm.get_embedding_model("embed-demo") + assert model.embed("hello world") == [5, 5] + [0] * 14 + + +@pytest.mark.parametrize( + "batch_size,expected_batches", + ( + (None, 100), + (10, 100), + ), +) +def test_embed_huge_list(batch_size, expected_batches): + model = llm.get_embedding_model("embed-demo") + huge_list = ("hello {}".format(i) for i in range(1000)) + kwargs = {} + if batch_size: + kwargs["batch_size"] = batch_size + results = model.embed_multi(huge_list, **kwargs) + assert repr(type(results)) == "" + first_twos = {} + for result in results: + key = (result[0], result[1]) + first_twos[key] = first_twos.get(key, 0) + 1 + assert first_twos == {(5, 1): 10, (5, 2): 90, (5, 3): 900} + assert model.batch_count == expected_batches + + +def test_embed_store(collection): + collection.embed("3", "hello world again", store=True) + assert collection.db["embeddings"].count == 3 + assert ( + next(collection.db["embeddings"].rows_where("id = ?", ["3"]))["content"] + == "hello world again" + ) + + +def test_embed_metadata(collection): + collection.embed("3", "hello yet again", metadata={"foo": "bar"}, store=True) + assert collection.db["embeddings"].count == 3 + assert json.loads( + next(collection.db["embeddings"].rows_where("id = ?", ["3"]))["metadata"] + ) == {"foo": "bar"} + entry = collection.similar("hello yet again")[0] + assert entry.id == "3" + assert entry.metadata == {"foo": "bar"} + assert entry.content == "hello yet again" + + +def test_collection(collection): + assert collection.id == 1 + assert collection.count() == 2 + # Check that the embeddings are there + rows = list(collection.db["embeddings"].rows) + assert rows == [ + { + "collection_id": 1, + "id": "1", + "embedding": llm.encode([5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + "content": None, + "content_blob": None, + "content_hash": collection.content_hash("hello world"), + "metadata": None, + "updated": ANY, + }, + { + "collection_id": 1, + "id": "2", + "embedding": llm.encode([7, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + "content": None, + "content_blob": None, + "content_hash": collection.content_hash("goodbye world"), + "metadata": None, + "updated": ANY, + }, + ] + assert isinstance(rows[0]["updated"], int) and rows[0]["updated"] > 0 + + +def test_similar(collection): + results = list(collection.similar("hello world")) + assert results == [ + Entry(id="1", score=pytest.approx(0.9999999999999999)), + Entry(id="2", score=pytest.approx(0.9863939238321437)), + ] + + +def test_similar_prefixed(collection): + results = list(collection.similar("hello world", prefix="2")) + assert results == [ + Entry(id="2", score=pytest.approx(0.9863939238321437)), + ] + + +def test_similar_by_id(collection): + results = list(collection.similar_by_id("1")) + assert results == [ + Entry(id="2", score=pytest.approx(0.9863939238321437)), + ] + + +@pytest.mark.parametrize( + "batch_size,expected_batches", + ( + (None, 100), + (5, 200), + ), +) +@pytest.mark.parametrize("with_metadata", (False, True)) +def test_embed_multi(with_metadata, batch_size, expected_batches): + db = sqlite_utils.Database(memory=True) + collection = llm.Collection("test", db, model_id="embed-demo") + model = collection.model() + assert getattr(model, "batch_count", 0) == 0 + ids_and_texts = ((str(i), "hello {}".format(i)) for i in range(1000)) + kwargs = {} + if batch_size is not None: + kwargs["batch_size"] = batch_size + if with_metadata: + ids_and_texts = ((id, text, {"meta": id}) for id, text in ids_and_texts) + collection.embed_multi_with_metadata(ids_and_texts, **kwargs) + else: + # Exercise store=True here too + collection.embed_multi(ids_and_texts, store=True, **kwargs) + rows = list(db["embeddings"].rows) + assert len(rows) == 1000 + rows_with_metadata = [row for row in rows if row["metadata"] is not None] + rows_with_content = [row for row in rows if row["content"] is not None] + if with_metadata: + assert len(rows_with_metadata) == 1000 + assert len(rows_with_content) == 0 + else: + assert len(rows_with_metadata) == 0 + assert len(rows_with_content) == 1000 + # Every row should have content_hash set + assert all(row["content_hash"] is not None for row in rows) + # Check batch count + assert collection.model().batch_count == expected_batches + + +def test_collection_delete(collection): + db = collection.db + assert db["embeddings"].count == 2 + assert db["collections"].count == 1 + collection.delete() + assert db["embeddings"].count == 0 + assert db["collections"].count == 0 + + +def test_binary_only_and_text_only_embedding_models(): + binary_only = llm.get_embedding_model("embed-binary-only") + text_only = llm.get_embedding_model("embed-text-only") + + assert binary_only.supports_binary + assert not binary_only.supports_text + assert not text_only.supports_binary + assert text_only.supports_text + + with pytest.raises(ValueError): + binary_only.embed("hello world") + + binary_only.embed(b"hello world") + + with pytest.raises(ValueError): + text_only.embed(b"hello world") + + text_only.embed("hello world") + + # Try the multi versions too + # Have to call list() on this or the generator is not evaluated + with pytest.raises(ValueError): + list(binary_only.embed_multi(["hello world"])) + + list(binary_only.embed_multi([b"hello world"])) + + with pytest.raises(ValueError): + list(text_only.embed_multi([b"hello world"])) + + list(text_only.embed_multi(["hello world"])) diff --git a/tests/test_embed_cli.py b/tests/test_embed_cli.py new file mode 100644 index 0000000..c8e8b8c --- /dev/null +++ b/tests/test_embed_cli.py @@ -0,0 +1,715 @@ +from click.testing import CliRunner +from llm.cli import cli +from llm import Collection +import json +import pathlib +import pytest +import sqlite_utils +import sys +from unittest.mock import ANY + + +@pytest.mark.parametrize( + "format_,expected", + ( + ("json", "[5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]\n"), + ( + "base64", + ( + "AACgQAAAoEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\n" + ), + ), + ( + "hex", + ( + "0000a0400000a04000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000" + "00000000000000\n" + ), + ), + ( + "blob", + ( + b"\x00\x00\xef\xbf\xbd@\x00\x00\xef\xbf\xbd@\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\n" + ).decode("utf-8"), + ), + ), +) +@pytest.mark.parametrize("scenario", ("argument", "file", "stdin")) +def test_embed_output_format(tmpdir, format_, expected, scenario): + runner = CliRunner() + args = ["embed", "--format", format_, "-m", "embed-demo"] + input = None + if scenario == "argument": + args.extend(["-c", "hello world"]) + elif scenario == "file": + path = tmpdir / "input.txt" + path.write_text("hello world", "utf-8") + args.extend(["-i", str(path)]) + elif scenario == "stdin": + input = "hello world" + args.extend(["-i", "-"]) + result = runner.invoke(cli, args, input=input) + assert result.exit_code == 0 + assert result.output == expected + + +@pytest.mark.parametrize( + "args,expected_error", + ((["-c", "Content", "stories"], "Must provide both collection and id"),), +) +def test_embed_errors(args, expected_error): + runner = CliRunner() + result = runner.invoke(cli, ["embed"] + args) + assert result.exit_code == 1 + assert expected_error in result.output + + +@pytest.mark.parametrize( + "metadata,metadata_error", + ( + (None, None), + ('{"foo": "bar"}', None), + ('{"foo": [1, 2, 3]}', None), + ("[1, 2, 3]", "metadata must be a JSON object"), # Must be a dictionary + ('{"foo": "incomplete}', "metadata must be valid JSON"), + ), +) +def test_embed_store(user_path, metadata, metadata_error): + embeddings_db = user_path / "embeddings.db" + assert not embeddings_db.exists() + runner = CliRunner() + result = runner.invoke(cli, ["embed", "-c", "hello", "-m", "embed-demo"]) + assert result.exit_code == 0 + # Should not have created the table + assert not embeddings_db.exists() + # Now run it to store + args = ["embed", "-c", "hello", "-m", "embed-demo", "items", "1"] + if metadata is not None: + args.extend(("--metadata", metadata)) + result = runner.invoke(cli, args) + if metadata_error: + # Should have returned an error message about invalid metadata + assert result.exit_code == 2 + assert metadata_error in result.output + return + # No error, should have succeeded and stored the data + assert result.exit_code == 0 + assert embeddings_db.exists() + # Check the contents + db = sqlite_utils.Database(str(embeddings_db)) + rows = list(db["collections"].rows) + assert rows == [{"id": 1, "name": "items", "model": "embed-demo"}] + expected_metadata = None + if metadata and not metadata_error: + expected_metadata = metadata + rows = list(db["embeddings"].rows) + assert rows == [ + { + "collection_id": 1, + "id": "1", + "embedding": ( + b"\x00\x00\xa0@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00" + ), + "content": None, + "content_blob": None, + "content_hash": Collection.content_hash("hello"), + "metadata": expected_metadata, + "updated": ANY, + } + ] + # Should show up in 'llm collections list' + for is_json in (False, True): + args = ["collections"] + if is_json: + args.extend(["list", "--json"]) + result2 = runner.invoke(cli, args) + assert result2.exit_code == 0 + if is_json: + assert json.loads(result2.output) == [ + {"name": "items", "model": "embed-demo", "num_embeddings": 1} + ] + else: + assert result2.output == "items: embed-demo\n 1 embedding\n" + + # And test deleting it too + result = runner.invoke(cli, ["collections", "delete", "items"]) + assert result.exit_code == 0 + assert db["collections"].count == 0 + assert db["embeddings"].count == 0 + + +def test_embed_store_binary(user_path): + runner = CliRunner() + args = ["embed", "-m", "embed-demo", "items", "2", "--binary", "--store"] + result = runner.invoke(cli, args, input=b"\x00\x01\x02") + assert result.exit_code == 0 + db = sqlite_utils.Database(str(user_path / "embeddings.db")) + rows = list(db["embeddings"].rows) + assert rows == [ + { + "collection_id": 1, + "id": "2", + "embedding": ( + b"\x00\x00@@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + ), + "content": None, + "content_blob": b"\x00\x01\x02", + "content_hash": b'\xb9_g\xf6\x1e\xbb\x03a\x96"\xd7\x98\xf4_\xc2\xd3', + "metadata": None, + "updated": ANY, + } + ] + + +def test_collection_delete_errors(user_path): + db = sqlite_utils.Database(str(user_path / "embeddings.db")) + collection = Collection("items", db, model_id="embed-demo") + collection.embed("1", "hello") + assert db["collections"].count == 1 + assert db["embeddings"].count == 1 + runner = CliRunner() + result = runner.invoke( + cli, ["collections", "delete", "does-not-exist"], catch_exceptions=False + ) + assert result.exit_code == 1 + assert "Collection does not exist" in result.output + assert db["collections"].count == 1 + + +@pytest.mark.parametrize( + "args,expected_error", + ( + ([], "Missing argument 'COLLECTION'"), + (["badcollection", "-c", "content"], "Collection does not exist"), + (["demo", "bad-id"], "ID not found in collection"), + ), +) +def test_similar_errors(args, expected_error, user_path_with_embeddings): + runner = CliRunner() + result = runner.invoke(cli, ["similar"] + args, catch_exceptions=False) + assert result.exit_code != 0 + assert expected_error in result.output + + +def test_similar_by_id_cli(user_path_with_embeddings): + runner = CliRunner() + result = runner.invoke(cli, ["similar", "demo", "1"], catch_exceptions=False) + assert result.exit_code == 0 + assert json.loads(result.output) == { + "id": "2", + "score": pytest.approx(0.9863939238321437), + "content": "goodbye world", + "metadata": None, + } + + +@pytest.mark.parametrize("option", ("-p", "--plain")) +def test_similar_by_id_cli_output_plain(user_path_with_embeddings, option): + runner = CliRunner() + result = runner.invoke( + cli, ["similar", "demo", "1", option], catch_exceptions=False + ) + assert result.exit_code == 0 + # Replace score with a placeholder + output = result.output.split("(")[0] + "(score)" + result.output.split(")")[1] + assert output == "2 (score)\n\n goodbye world\n\n" + + +@pytest.mark.parametrize("scenario", ("argument", "file", "stdin")) +def test_similar_by_content_cli(tmpdir, user_path_with_embeddings, scenario): + runner = CliRunner() + args = ["similar", "demo"] + input = None + if scenario == "argument": + args.extend(["-c", "hello world"]) + elif scenario == "file": + path = tmpdir / "content.txt" + path.write_text("hello world", "utf-8") + args.extend(["-i", str(path)]) + elif scenario == "stdin": + input = "hello world" + args.extend(["-i", "-"]) + result = runner.invoke(cli, args, input=input, catch_exceptions=False) + assert result.exit_code == 0 + lines = [line for line in result.output.splitlines() if line.strip()] + assert len(lines) == 2 + assert json.loads(lines[0]) == { + "id": "1", + "score": pytest.approx(0.9999999999999999), + "content": "hello world", + "metadata": None, + } + assert json.loads(lines[1]) == { + "id": "2", + "score": pytest.approx(0.9863939238321437), + "content": "goodbye world", + "metadata": None, + } + + +@pytest.mark.parametrize( + "prefix,expected_result", + ( + ( + 1, + { + "id": "1", + "score": pytest.approx(0.7071067811865475), + "content": "hello world", + "metadata": None, + }, + ), + ( + 2, + { + "id": "2", + "score": pytest.approx(0.8137334712067349), + "content": "goodbye world", + "metadata": None, + }, + ), + ), +) +def test_similar_by_content_prefixed( + user_path_with_embeddings, prefix, expected_result +): + runner = CliRunner() + result = runner.invoke( + cli, + ["similar", "demo", "-c", "world", "--prefix", prefix, "-n", "1"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert json.loads(result.output) == expected_result + + +@pytest.mark.parametrize("use_stdin", (False, True)) +@pytest.mark.parametrize("prefix", (None, "prefix")) +@pytest.mark.parametrize("prepend", (None, "search_document: ")) +@pytest.mark.parametrize( + "filename,content", + ( + ("phrases.csv", "id,phrase\n1,hello world\n2,goodbye world"), + ("phrases.tsv", "id\tphrase\n1\thello world\n2\tgoodbye world"), + ( + "phrases.jsonl", + '{"id": 1, "phrase": "hello world"}\n{"id": 2, "phrase": "goodbye world"}', + ), + ( + "phrases.json", + '[{"id": 1, "phrase": "hello world"}, {"id": 2, "phrase": "goodbye world"}]', + ), + ), +) +def test_embed_multi_file_input(tmpdir, use_stdin, prefix, prepend, filename, content): + db_path = tmpdir / "embeddings.db" + args = ["embed-multi", "phrases", "-d", str(db_path), "-m", "embed-demo"] + input = None + if use_stdin: + input = content + args.append("-") + else: + path = tmpdir / filename + path.write_text(content, "utf-8") + args.append(str(path)) + if prefix: + args.extend(("--prefix", prefix)) + if prepend: + args.extend(("--prepend", prepend)) + # Auto-detection can't detect JSON-nl, so make that explicit + if filename.endswith(".jsonl"): + args.extend(("--format", "nl")) + runner = CliRunner() + result = runner.invoke(cli, args, input=input, catch_exceptions=False) + assert result.exit_code == 0 + # Check that everything was embedded correctly + db = sqlite_utils.Database(str(db_path)) + assert db["embeddings"].count == 2 + ids = [row["id"] for row in db["embeddings"].rows] + expected_ids = ["1", "2"] + if prefix: + expected_ids = ["prefix1", "prefix2"] + assert ids == expected_ids + + +def test_embed_multi_files_binary_store(tmpdir): + db_path = tmpdir / "embeddings.db" + args = ["embed-multi", "binfiles", "-d", str(db_path), "-m", "embed-demo"] + bin_path = tmpdir / "file.bin" + bin_path.write(b"\x00\x01\x02") + args.extend(("--files", str(tmpdir), "*.bin", "--store", "--binary")) + runner = CliRunner() + result = runner.invoke(cli, args, catch_exceptions=False) + assert result.exit_code == 0 + db = sqlite_utils.Database(str(db_path)) + assert db["embeddings"].count == 1 + row = list(db["embeddings"].rows)[0] + assert row == { + "collection_id": 1, + "id": "file.bin", + "embedding": ( + b"\x00\x00@@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + ), + "content": None, + "content_blob": b"\x00\x01\x02", + "content_hash": b'\xb9_g\xf6\x1e\xbb\x03a\x96"\xd7\x98\xf4_\xc2\xd3', + "metadata": None, + "updated": ANY, + } + + +@pytest.mark.parametrize("use_other_db", (True, False)) +@pytest.mark.parametrize("prefix", (None, "prefix")) +@pytest.mark.parametrize("prepend", (None, "search_document: ")) +def test_embed_multi_sql(tmpdir, use_other_db, prefix, prepend): + db_path = str(tmpdir / "embeddings.db") + db = sqlite_utils.Database(db_path) + extra_args = [] + if use_other_db: + db_path2 = str(tmpdir / "other.db") + db = sqlite_utils.Database(db_path2) + extra_args = ["--attach", "other", db_path2] + + if prefix: + extra_args.extend(("--prefix", prefix)) + if prepend: + extra_args.extend(("--prepend", prepend)) + + db["content"].insert_all( + [ + {"id": 1, "name": "cli", "description": "Command line interface"}, + {"id": 2, "name": "sql", "description": "Structured query language"}, + ], + pk="id", + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "embed-multi", + "stuff", + "-d", + db_path, + "--sql", + "select * from content", + "-m", + "embed-demo", + "--store", + ] + + extra_args, + ) + assert result.exit_code == 0 + embeddings_db = sqlite_utils.Database(db_path) + assert embeddings_db["embeddings"].count == 2 + rows = list(embeddings_db.query("select id, content from embeddings order by id")) + assert rows == [ + { + "id": (prefix or "") + "1", + "content": (prepend or "") + "cli Command line interface", + }, + { + "id": (prefix or "") + "2", + "content": (prepend or "") + "sql Structured query language", + }, + ] + + +def test_embed_multi_batch_size(embed_demo, tmpdir): + db_path = str(tmpdir / "data.db") + runner = CliRunner() + sql = """ + with recursive cte (id) as ( + select 1 + union all + select id+1 from cte where id < 100 + ) + select id, 'Row ' || cast(id as text) as value from cte + """ + assert getattr(embed_demo, "batch_count", 0) == 0 + result = runner.invoke( + cli, + [ + "embed-multi", + "rows", + "--sql", + sql, + "-d", + db_path, + "-m", + "embed-demo", + "--store", + "--batch-size", + "8", + ], + ) + assert result.exit_code == 0 + db = sqlite_utils.Database(db_path) + assert db["embeddings"].count == 100 + assert embed_demo.batch_count == 13 + + +@pytest.fixture +def multi_files(tmpdir): + db_path = str(tmpdir / "files.db") + files = tmpdir / "files" + for filename, content in ( + ("file1.txt", b"hello world"), + ("file2.txt", b"goodbye world"), + ("nested/one.txt", b"one"), + ("nested/two.txt", b"two"), + ("nested/more/three.txt", b"three"), + # This tests the fallback to latin-1 encoding: + ("nested/more/ignored.ini", b"Has weird \x96 character"), + ): + path = pathlib.Path(files / filename) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return db_path, tmpdir / "files" + + +@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") +@pytest.mark.parametrize("scenario", ("single", "multi")) +@pytest.mark.parametrize("prepend", (None, "search_document: ")) +def test_embed_multi_files(multi_files, scenario, prepend): + db_path, files = multi_files + for filename, content in ( + ("file1.txt", b"hello world"), + ("file2.txt", b"goodbye world"), + ("nested/one.txt", b"one"), + ("nested/two.txt", b"two"), + ("nested/more/three.txt", b"three"), + # This tests the fallback to latin-1 encoding: + ("nested/more.txt/ignored.ini", b"Has weird \x96 character"), + ): + path = pathlib.Path(files / filename) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + + extra_args = [] + + if prepend: + extra_args.extend(("--prepend", prepend)) + if scenario == "single": + extra_args.extend(["--files", str(files), "**/*.txt"]) + else: + extra_args.extend( + [ + "--files", + str(files / "nested" / "more"), + "**/*.ini", + "--files", + str(files / "nested"), + "*.txt", + ] + ) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "embed-multi", + "files", + "-d", + db_path, + "-m", + "embed-demo", + "--store", + ] + + extra_args, + ) + assert result.exit_code == 0 + embeddings_db = sqlite_utils.Database(db_path) + rows = list(embeddings_db.query("select id, content from embeddings order by id")) + if scenario == "single": + assert rows == [ + {"id": "file1.txt", "content": (prepend or "") + "hello world"}, + {"id": "file2.txt", "content": (prepend or "") + "goodbye world"}, + {"id": "nested/more/three.txt", "content": (prepend or "") + "three"}, + {"id": "nested/one.txt", "content": (prepend or "") + "one"}, + {"id": "nested/two.txt", "content": (prepend or "") + "two"}, + ] + else: + assert rows == [ + { + "id": "ignored.ini", + "content": (prepend or "") + "Has weird \x96 character", + }, + {"id": "one.txt", "content": (prepend or "") + "one"}, + {"id": "two.txt", "content": (prepend or "") + "two"}, + ] + + +@pytest.mark.parametrize( + "args,expected_error", + ((["not-a-dir", "*.txt"], "Invalid directory: not-a-dir"),), +) +def test_embed_multi_files_errors(multi_files, args, expected_error): + runner = CliRunner() + result = runner.invoke( + cli, + ["embed-multi", "files", "-m", "embed-demo", "--files"] + args, + ) + assert result.exit_code == 2 + assert expected_error in result.output + + +@pytest.mark.parametrize( + "extra_args,expected_error", + ( + # With no args default utf-8 with latin-1 fallback should work + ([], None), + (["--encoding", "utf-8"], "Could not decode text in file"), + (["--encoding", "latin-1"], None), + (["--encoding", "latin-1", "--encoding", "utf-8"], None), + (["--encoding", "utf-8", "--encoding", "latin-1"], None), + ), +) +def test_embed_multi_files_encoding(multi_files, extra_args, expected_error): + db_path, files = multi_files + runner = CliRunner() + result = runner.invoke( + cli, + [ + "embed-multi", + "files", + "-d", + db_path, + "-m", + "embed-demo", + "--files", + str(files / "nested" / "more"), + "*.ini", + "--store", + ] + + extra_args, + ) + if expected_error: + # Should still succeed with 0, but show a warning + assert result.exit_code == 0 + assert expected_error in result.stderr + else: + assert result.exit_code == 0 + assert not result.stderr + embeddings_db = sqlite_utils.Database(db_path) + rows = list( + embeddings_db.query("select id, content from embeddings order by id") + ) + assert rows == [ + {"id": "ignored.ini", "content": "Has weird \x96 character"}, + ] + + +def test_default_embedding_model(): + runner = CliRunner() + result = runner.invoke(cli, ["embed-models", "default"]) + assert result.exit_code == 0 + assert result.output == "\n" + result2 = runner.invoke(cli, ["embed-models", "default", "ada-002"]) + assert result2.exit_code == 0 + result3 = runner.invoke(cli, ["embed-models", "default"]) + assert result3.exit_code == 0 + assert result3.output == "text-embedding-ada-002\n" + result4 = runner.invoke(cli, ["embed-models", "default", "--remove-default"]) + assert result4.exit_code == 0 + result5 = runner.invoke(cli, ["embed-models", "default"]) + assert result5.exit_code == 0 + assert result5.output == "\n" + # Now set the default and actually use it + result6 = runner.invoke(cli, ["embed-models", "default", "embed-demo"]) + assert result6.exit_code == 0 + result7 = runner.invoke(cli, ["embed", "-c", "hello world"]) + assert result7.exit_code == 0 + assert result7.output == "[5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]\n" + + +@pytest.mark.parametrize( + "args,expected_model_id", + ( + (["-q", "text-embedding-3-large"], "text-embedding-3-large"), + (["-q", "text", "-q", "3"], "text-embedding-3-large"), + ), +) +def test_llm_embed_models_query(user_path, args, expected_model_id): + runner = CliRunner() + result = runner.invoke(cli, ["embed-models"] + args, catch_exceptions=False) + assert result.exit_code == 0 + assert expected_model_id in result.output + + +@pytest.mark.parametrize("default_is_set", (False, True)) +@pytest.mark.parametrize("command", ("embed", "embed-multi")) +def test_default_embed_model_errors(user_path, default_is_set, command): + runner = CliRunner() + if default_is_set: + (user_path / "default_embedding_model.txt").write_text( + "embed-demo", encoding="utf8" + ) + args = [] + input = None + if command == "embed-multi": + args = ["embed-multi", "example", "-"] + input = "id,name\n1,hello" + else: + args = ["embed", "example", "1", "-c", "hello world"] + result = runner.invoke(cli, args, input=input, catch_exceptions=False) + if default_is_set: + assert result.exit_code == 0 + else: + assert result.exit_code == 1 + assert ( + "You need to specify an embedding model (no default model is set)" + in result.output + ) + # Now set the default model and try again + result2 = runner.invoke(cli, ["embed-models", "default", "embed-demo"]) + assert result2.exit_code == 0 + result3 = runner.invoke(cli, args, input=input, catch_exceptions=False) + assert result3.exit_code == 0 + # At the end of this, there should be 2 embeddings + db = sqlite_utils.Database(str(user_path / "embeddings.db")) + assert db["embeddings"].count == 1 + + +def test_duplicate_content_embedded_only_once(embed_demo): + # content_hash should avoid embedding the same content twice + # per collection + db = sqlite_utils.Database(memory=True) + assert len(embed_demo.embedded_content) == 0 + collection = Collection("test", db, model_id="embed-demo") + collection.embed("1", "hello world") + assert len(embed_demo.embedded_content) == 1 + collection.embed("2", "goodbye world") + assert db["embeddings"].count == 2 + assert len(embed_demo.embedded_content) == 2 + collection.embed("1", "hello world") + assert db["embeddings"].count == 2 + assert len(embed_demo.embedded_content) == 2 + # The same string in another collection should be embedded + c2 = Collection("test2", db, model_id="embed-demo") + c2.embed("1", "hello world") + assert db["embeddings"].count == 3 + assert len(embed_demo.embedded_content) == 3 + + # Same again for embed_multi + collection.embed_multi( + (("1", "hello world"), ("2", "goodbye world"), ("3", "this is new")) + ) + # Should have only embedded one more thing + assert db["embeddings"].count == 4 + assert len(embed_demo.embedded_content) == 4 diff --git a/tests/test_encode_decode.py b/tests/test_encode_decode.py new file mode 100644 index 0000000..1310c6d --- /dev/null +++ b/tests/test_encode_decode.py @@ -0,0 +1,19 @@ +import llm +import pytest +import numpy as np + + +@pytest.mark.parametrize( + "array", + ( + (0.0, 1.0, 1.5), + (3423.0, 222.0, -1234.5), + ), +) +def test_roundtrip(array): + encoded = llm.encode(array) + decoded = llm.decode(encoded) + assert decoded == array + # Try with numpy as well + numpy_decoded = np.frombuffer(encoded, "= { + "model": "gpt-4o-mini", + "prompt": "three names \nfor a pet pelican", + "system": None, + "prompt_json": { + "messages": [ + {"role": "user", "content": "three names \nfor a pet pelican"} + ] + }, + "options_json": {}, + "response": "Bob, Alice, Eve", + "response_json": { + "model": "gpt-4o-mini", + "choices": [{"message": {"content": {"$": f"r:{row['id']}"}}}], + }, + # This doesn't have the \n after three names: + "conversation_name": "three names for a pet pelican", + "conversation_model": "gpt-4o-mini", + }.items() + ) + + +@mock.patch.dict(os.environ, {"OPENAI_API_KEY": "X"}) +@pytest.mark.parametrize("async_", (False, True)) +def test_llm_prompt_continue(httpx_mock, user_path, async_): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-4o-mini", + "usage": {}, + "choices": [{"message": {"content": "Bob, Alice, Eve"}}], + }, + headers={"Content-Type": "application/json"}, + ) + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-4o-mini", + "usage": {}, + "choices": [{"message": {"content": "Terry"}}], + }, + headers={"Content-Type": "application/json"}, + ) + + log_path = user_path / "logs.db" + log_db = sqlite_utils.Database(str(log_path)) + log_db["responses"].delete_where() + + # First prompt + runner = CliRunner() + args = ["three names \nfor a pet pelican", "--no-stream"] + ( + ["--async"] if async_ else [] + ) + result = runner.invoke(cli, args, catch_exceptions=False) + assert result.exit_code == 0, result.output + assert result.output == "Bob, Alice, Eve\n" + + # Should be logged + rows = list(log_db["responses"].rows) + assert len(rows) == 1 + + # Now ask a follow-up + args2 = ["one more", "-c", "--no-stream"] + (["--async"] if async_ else []) + result2 = runner.invoke(cli, args2, catch_exceptions=False) + assert result2.exit_code == 0, result2.output + assert result2.output == "Terry\n" + + rows = list(log_db["responses"].rows) + assert len(rows) == 2 + + +@pytest.mark.parametrize( + "args,expect_just_code", + ( + (["-x"], True), + (["--extract"], True), + (["-x", "--async"], True), + (["--extract", "--async"], True), + # Use --no-stream here to ensure it passes test same as -x/--extract cases + (["--no-stream"], False), + ), +) +def test_extract_fenced_code( + mocked_openai_chat_returning_fenced_code, args, expect_just_code +): + runner = CliRunner() + result = runner.invoke( + cli, + ["-m", "gpt-4o-mini", "--key", "x", "Write code"] + args, + catch_exceptions=False, + ) + output = result.output + if expect_just_code: + assert "```" not in output + else: + assert "```" in output + + +def test_openai_chat_stream(mocked_openai_chat_stream, user_path): + runner = CliRunner() + result = runner.invoke(cli, ["-m", "gpt-3.5-turbo", "--key", "x", "Say hi"]) + assert result.exit_code == 0 + assert result.output == "Hi.\n" + + +def test_openai_completion(mocked_openai_completion, user_path): + log_path = user_path / "logs.db" + log_db = sqlite_utils.Database(str(log_path)) + log_db["responses"].delete_where() + runner = CliRunner() + result = runner.invoke( + cli, + [ + "-m", + "gpt-3.5-turbo-instruct", + "Say this is a test", + "--no-stream", + "--key", + "x", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert result.output == "\n\nThis is indeed a test\n" + + # Should have requested 256 tokens + last_request = mocked_openai_completion.get_requests()[-1] + assert json.loads(last_request.content) == { + "model": "gpt-3.5-turbo-instruct", + "prompt": "Say this is a test", + "stream": False, + "max_tokens": 256, + } + + # Check it was logged + rows = list(log_db["responses"].rows) + assert len(rows) == 1 + expected = { + "model": "gpt-3.5-turbo-instruct", + "prompt": "Say this is a test", + "system": None, + "prompt_json": '{"messages": ["Say this is a test"]}', + "options_json": "{}", + "response": "\n\nThis is indeed a test", + } + row = rows[0] + assert expected.items() <= row.items() + + +def test_openai_completion_system_prompt_error(): + runner = CliRunner() + result = runner.invoke( + cli, + [ + "-m", + "gpt-3.5-turbo-instruct", + "Say this is a test", + "--no-stream", + "--key", + "x", + "--system", + "system prompts not allowed", + ], + ) + assert result.exit_code == 1 + assert ( + "System prompts are not supported for OpenAI completion models" in result.output + ) + + +def test_openai_completion_logprobs_stream( + mocked_openai_completion_logprobs_stream, user_path +): + log_path = user_path / "logs.db" + log_db = sqlite_utils.Database(str(log_path)) + log_db["responses"].delete_where() + runner = CliRunner() + args = [ + "-m", + "gpt-3.5-turbo-instruct", + "Say hi", + "-o", + "logprobs", + "2", + "--key", + "x", + ] + result = runner.invoke(cli, args, catch_exceptions=False) + assert result.exit_code == 0 + assert result.output == "\n\nHi.\n" + rows = list(log_db["responses"].rows) + assert len(rows) == 1 + row = rows[0] + assert json.loads(row["response_json"]) == { + "content": {"$": f'r:{row["id"]}'}, + "logprobs": [ + {"text": "\n\n", "top_logprobs": [{"\n\n": -0.6, "\n": -1.9}]}, + {"text": "Hi", "top_logprobs": [{"Hi": -1.1, "Hello": -0.7}]}, + {"text": ".", "top_logprobs": [{".": -1.1, "!": -0.9}]}, + {"text": "", "top_logprobs": []}, + ], + "id": "cmpl-80MdSaou7NnPuff5ZyRMysWBmgSPS", + "object": "text_completion", + "model": "gpt-3.5-turbo-instruct", + "created": 1695097702, + } + + +def test_openai_completion_logprobs_nostream( + mocked_openai_completion_logprobs, user_path +): + log_path = user_path / "logs.db" + log_db = sqlite_utils.Database(str(log_path)) + log_db["responses"].delete_where() + runner = CliRunner() + args = [ + "-m", + "gpt-3.5-turbo-instruct", + "Say hi", + "-o", + "logprobs", + "2", + "--key", + "x", + "--no-stream", + ] + result = runner.invoke(cli, args, catch_exceptions=False) + assert result.exit_code == 0 + assert result.output == "\n\nHi.\n" + rows = list(log_db["responses"].rows) + assert len(rows) == 1 + row = rows[0] + assert json.loads(row["response_json"]) == { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "logprobs": { + "text_offset": [16, 18, 20], + "token_logprobs": [-0.6, -1.1, -0.9], + "tokens": ["\n\n", "Hi", "1"], + "top_logprobs": [ + {"\n": -1.9, "\n\n": -0.6}, + {"Hello": -0.7, "Hi": -1.1}, + {"!": -1.1, ".": -0.9}, + ], + }, + "text": {"$": f"r:{row['id']}"}, + } + ], + "created": 1695097747, + "id": "cmpl-80MeBfKJutM0uMNJkRrebJLeP3bxL", + "model": "gpt-3.5-turbo-instruct", + "object": "text_completion", + "usage": {"completion_tokens": 3, "prompt_tokens": 5, "total_tokens": 8}, + } + + +EXTRA_MODELS_YAML = """ +- model_id: orca + model_name: orca-mini-3b + api_base: "http://localai.localhost" +- model_id: completion-babbage + model_name: babbage + api_base: "http://localai.localhost" + completion: 1 +""" + + +def test_openai_localai_configuration(mocked_localai, user_path): + log_path = user_path / "logs.db" + sqlite_utils.Database(str(log_path)) + # Write the configuration file + config_path = user_path / "extra-openai-models.yaml" + config_path.write_text(EXTRA_MODELS_YAML, "utf-8") + # Run the prompt + runner = CliRunner() + prompt = "three names \nfor a pet pelican" + result = runner.invoke(cli, ["--no-stream", "--model", "orca", prompt]) + assert result.exit_code == 0 + assert result.output == "Bob, Alice, Eve\n" + last_request = mocked_localai.get_requests()[-1] + assert json.loads(last_request.content) == { + "model": "orca-mini-3b", + "messages": [{"role": "user", "content": "three names \nfor a pet pelican"}], + "stream": False, + } + # And check the completion model too + result2 = runner.invoke(cli, ["--no-stream", "--model", "completion-babbage", "hi"]) + assert result2.exit_code == 0 + assert result2.output == "Hello\n" + last_request2 = mocked_localai.get_requests()[-1] + assert json.loads(last_request2.content) == { + "model": "babbage", + "prompt": "hi", + "stream": False, + } + + +def test_extra_openai_models_async(user_path): + from llm.default_plugins.openai_models import AsyncChat + + config_path = user_path / "extra-openai-models.yaml" + config_path.write_text(EXTRA_MODELS_YAML, "utf-8") + async_model = llm.get_async_model("orca") + assert isinstance(async_model, AsyncChat) + assert async_model.model_id == "orca" + assert async_model.model_name == "orca-mini-3b" + assert async_model.api_base == "http://localai.localhost" + assert async_model.needs_key is None + # Completion models should not have an async variant + with pytest.raises(llm.UnknownModelError): + llm.get_async_model("completion-babbage") + + +@pytest.mark.parametrize( + "args,exit_code", + ( + (["-q", "mo", "-q", "ck"], 0), + (["-q", "mock"], 0), + (["-q", "badmodel"], 1), + (["-q", "mock", "-q", "badmodel"], 1), + ), +) +def test_prompt_select_model_with_queries(mock_model, user_path, args, exit_code): + runner = CliRunner() + result = runner.invoke( + cli, + args + ["hello"], + catch_exceptions=False, + ) + assert result.exit_code == exit_code + + +EXPECTED_OPTIONS = """ +OpenAI Chat: gpt-4o (aliases: 4o) + Options: + temperature: float + What sampling temperature to use, between 0 and 2. Higher values like + 0.8 will make the output more random, while lower values like 0.2 will + make it more focused and deterministic. + max_tokens: int + Maximum number of tokens to generate. + top_p: float + An alternative to sampling with temperature, called nucleus sampling, + where the model considers the results of the tokens with top_p + probability mass. So 0.1 means only the tokens comprising the top 10% + probability mass are considered. Recommended to use top_p or + temperature but not both. + frequency_penalty: float + Number between -2.0 and 2.0. Positive values penalize new tokens based + on their existing frequency in the text so far, decreasing the model's + likelihood to repeat the same line verbatim. + presence_penalty: float + Number between -2.0 and 2.0. Positive values penalize new tokens based + on whether they appear in the text so far, increasing the model's + likelihood to talk about new topics. + stop: str + A string where the API will stop generating further tokens. + logit_bias: dict, str + Modify the likelihood of specified tokens appearing in the completion. + Pass a JSON string like '{"1712":-100, "892":-100, "1489":-100}' + seed: int + Integer seed to attempt to sample deterministically + json_object: boolean + Output a valid JSON object {...}. Prompt must mention JSON. + Attachment types: + application/pdf, image/gif, image/jpeg, image/png, image/webp + Keys: + key: openai + env_var: OPENAI_API_KEY +""" + + +def test_llm_models_options(user_path): + runner = CliRunner() + result = runner.invoke(cli, ["models", "--options"], catch_exceptions=False) + assert result.exit_code == 0 + # Check for key components instead of exact string match + assert "OpenAI Chat: gpt-4o (aliases: 4o)" in result.output + assert " Options:" in result.output + assert " temperature: float" in result.output + assert " Keys:" in result.output + assert " key: openai" in result.output + assert " env_var: OPENAI_API_KEY" in result.output + assert "AsyncMockModel (async): mock" not in result.output + + +def test_prompt_options_shows_selected_model_options(user_path): + runner = CliRunner() + result = runner.invoke(cli, ["-m", "gpt-5.5", "--options"], catch_exceptions=False) + expected = runner.invoke( + cli, ["models", "-m", "gpt-5.5", "--options"], catch_exceptions=False + ) + assert result.exit_code == 0 + assert expected.exit_code == 0 + assert result.output == expected.output + assert "OpenAI Responses: gpt-5.5" in result.output + assert " Options:" in result.output + assert " reasoning_effort: str" in result.output + assert not (user_path / "logs.db").exists() + + +def test_llm_models_async(user_path): + runner = CliRunner() + result = runner.invoke(cli, ["models", "--async"], catch_exceptions=False) + assert result.exit_code == 0 + assert "AsyncMockModel (async): mock" in result.output + + +@pytest.mark.parametrize( + "args,expected_model_ids,unexpected_model_ids", + ( + (["-q", "gpt-4o"], ["OpenAI Chat: gpt-4o"], None), + (["-q", "mock"], ["MockModel: mock"], None), + (["--query", "mock"], ["MockModel: mock"], None), + ( + ["-q", "4o", "-q", "mini"], + ["OpenAI Chat: gpt-4o-mini"], + ["OpenAI Chat: gpt-4o "], + ), + ( + ["-m", "gpt-4o-mini", "-m", "gpt-4.5"], + ["OpenAI Chat: gpt-4o-mini", "OpenAI Chat: gpt-4.5"], + ["OpenAI Chat: gpt-4o "], + ), + ), +) +def test_llm_models_filter(user_path, args, expected_model_ids, unexpected_model_ids): + runner = CliRunner() + result = runner.invoke(cli, ["models"] + args, catch_exceptions=False) + assert result.exit_code == 0 + if expected_model_ids: + for expected_model_id in expected_model_ids: + assert expected_model_id in result.output + if unexpected_model_ids: + for unexpected_model_id in unexpected_model_ids: + assert unexpected_model_id not in result.output + + +def test_llm_user_dir(tmpdir, monkeypatch): + user_dir = str(tmpdir / "u") + monkeypatch.setenv("LLM_USER_PATH", user_dir) + assert not os.path.exists(user_dir) + user_dir2 = llm.user_dir() + assert user_dir == str(user_dir2) + assert os.path.exists(user_dir) + + +def test_model_defaults(tmpdir, monkeypatch): + user_dir = str(tmpdir / "u") + monkeypatch.setenv("LLM_USER_PATH", user_dir) + config_path = pathlib.Path(user_dir) / "default_model.txt" + assert not config_path.exists() + assert llm.get_default_model() == "gpt-4o-mini" + assert llm.get_model().model_id == "gpt-4o-mini" + llm.set_default_model("gpt-4o") + assert config_path.exists() + assert llm.get_default_model() == "gpt-4o" + assert llm.get_model().model_id == "gpt-4o" + + +def test_get_models(): + models = llm.get_models() + assert all(isinstance(model, (llm.Model, llm.KeyModel)) for model in models) + model_ids = [model.model_id for model in models] + assert "gpt-4o-mini" in model_ids + assert "gpt-5.4-mini" in model_ids + assert "gpt-5.4-nano" in model_ids + # Ensure no model_ids are duplicated + # https://github.com/simonw/llm/issues/667 + assert len(model_ids) == len(set(model_ids)) + + +def test_get_async_models(): + models = llm.get_async_models() + assert all( + isinstance(model, (llm.AsyncModel, llm.AsyncKeyModel)) for model in models + ) + model_ids = [model.model_id for model in models] + assert "gpt-4o-mini" in model_ids + assert "gpt-5.4-mini" in model_ids + assert "gpt-5.4-nano" in model_ids + + +def test_mock_model(mock_model): + mock_model.enqueue(["hello world"]) + mock_model.enqueue(["second"]) + model = llm.get_model("mock") + response = model.prompt(prompt="hello") + assert response.text() == "hello world" + assert str(response) == "hello world" + assert model.history[0][0].prompt == "hello" + assert response.usage() == Usage(input=1, output=1, details=None) + response2 = model.prompt(prompt="hello again") + assert response2.text() == "second" + assert response2.usage() == Usage(input=2, output=1, details=None) + + +class Dog(BaseModel): + name: str + age: int + + +dog_schema = { + "properties": { + "name": {"title": "Name", "type": "string"}, + "age": {"title": "Age", "type": "integer"}, + }, + "required": ["name", "age"], + "title": "Dog", + "type": "object", +} +dog = {"name": "Cleo", "age": 10} + + +@pytest.mark.parametrize("use_pydantic", (False, True)) +def test_schema(mock_model, use_pydantic): + assert dog_schema == Dog.model_json_schema() + mock_model.enqueue([json.dumps(dog)]) + response = mock_model.prompt( + "invent a dog", schema=Dog if use_pydantic else dog_schema + ) + assert json.loads(response.text()) == dog + assert response.prompt.schema == dog_schema + + +def test_model_environment_variable(monkeypatch): + monkeypatch.setenv("LLM_MODEL", "echo") + runner = CliRunner() + result = runner.invoke( + cli, + ["--no-stream", "hello", "-s", "sys"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert json.loads(result.output) == { + "prompt": "hello", + "system": "sys", + "attachments": [], + "stream": False, + "previous": [], + } + + +@pytest.mark.parametrize("use_filename", (True, False)) +def test_schema_via_cli(mock_model, tmpdir, monkeypatch, use_filename): + user_path = tmpdir / "user" + schema_path = tmpdir / "schema.json" + mock_model.enqueue([json.dumps(dog)]) + schema_value = '{"schema": "one"}' + with open(schema_path, "w") as f: + f.write(schema_value) + monkeypatch.setenv("LLM_USER_PATH", str(user_path)) + if use_filename: + schema_value = str(schema_path) + runner = CliRunner() + result = runner.invoke( + cli, + ["--schema", schema_value, "prompt", "-m", "mock"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert result.output == '{"name": "Cleo", "age": 10}\n' + # Should have created user_path and put a logs.db in it + assert (user_path / "logs.db").exists() + rows = list(sqlite_utils.Database(str(user_path / "logs.db"))["schemas"].rows) + assert rows == [ + {"id": "9a8ed2c9b17203f6d8905147234475b5", "content": '{"schema":"one"}'} + ] + if use_filename: + # Run it again to check that the ID option works now it's in the DB + result2 = runner.invoke( + cli, + ["--schema", "9a8ed2c9b17203f6d8905147234475b5", "prompt", "-m", "mock"], + catch_exceptions=False, + ) + assert result2.exit_code == 0 + + +@pytest.mark.parametrize( + "args,expected", + ( + ( + ["--schema", "name, age int"], + { + "type": "object", + "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, + "required": ["name", "age"], + }, + ), + ( + ["--schema-multi", "name, age int"], + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + "required": ["name", "age"], + }, + } + }, + "required": ["items"], + }, + ), + ), +) +def test_schema_using_dsl(mock_model, tmpdir, monkeypatch, args, expected): + user_path = tmpdir / "user" + mock_model.enqueue([json.dumps(dog)]) + monkeypatch.setenv("LLM_USER_PATH", str(user_path)) + runner = CliRunner() + result = runner.invoke( + cli, + ["prompt", "-m", "mock"] + args, + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert result.output == '{"name": "Cleo", "age": 10}\n' + rows = list(sqlite_utils.Database(str(user_path / "logs.db"))["schemas"].rows) + assert json.loads(rows[0]["content"]) == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("use_pydantic", (False, True)) +async def test_schema_async(async_mock_model, use_pydantic): + async_mock_model.enqueue([json.dumps(dog)]) + response = async_mock_model.prompt( + "invent a dog", schema=Dog if use_pydantic else dog_schema + ) + assert json.loads(await response.text()) == dog + assert response.prompt.schema == dog_schema + + +def test_mock_key_model(mock_key_model): + response = mock_key_model.prompt(prompt="hello", key="hi") + assert response.text() == "key: hi" + + +@pytest.mark.asyncio +async def test_mock_async_key_model(mock_async_key_model): + response = mock_async_key_model.prompt(prompt="hello", key="hi") + output = await response.text() + assert output == "async, key: hi" + + +def test_sync_on_done(mock_model): + mock_model.enqueue(["hello world"]) + model = llm.get_model("mock") + response = model.prompt(prompt="hello") + caught = [] + + def done(response): + caught.append(response) + + response.on_done(done) + assert len(caught) == 0 + str(response) + assert len(caught) == 1 + + +def test_schemas_dsl(): + runner = CliRunner() + result = runner.invoke(cli, ["schemas", "dsl", "name, age int, bio: short bio"]) + assert result.exit_code == 0 + assert json.loads(result.output) == { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "bio": {"type": "string", "description": "short bio"}, + }, + "required": ["name", "age", "bio"], + } + result2 = runner.invoke(cli, ["schemas", "dsl", "name, age int", "--multi"]) + assert result2.exit_code == 0 + assert json.loads(result2.output) == { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + "required": ["name", "age"], + }, + } + }, + "required": ["items"], + } + + +@mock.patch.dict(os.environ, {"OPENAI_API_KEY": "X"}) +@pytest.mark.parametrize("custom_database_path", (False, True)) +def test_llm_prompt_continue_with_database( + tmpdir, monkeypatch, httpx_mock, user_path, custom_database_path +): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-4o-mini", + "usage": {}, + "choices": [{"message": {"content": "Bob, Alice, Eve"}}], + }, + headers={"Content-Type": "application/json"}, + ) + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-4o-mini", + "usage": {}, + "choices": [{"message": {"content": "Terry"}}], + }, + headers={"Content-Type": "application/json"}, + ) + + user_path = tmpdir / "user" + custom_db_path = tmpdir / "custom_log.db" + monkeypatch.setenv("LLM_USER_PATH", str(user_path)) + + # First prompt + runner = CliRunner() + args = ["three names \nfor a pet pelican", "--no-stream"] + if custom_database_path: + args.extend(["--database", str(custom_db_path)]) + result = runner.invoke(cli, args, catch_exceptions=False) + assert result.exit_code == 0, result.output + assert result.output == "Bob, Alice, Eve\n" + + # Now ask a follow-up + args2 = ["one more", "-c", "--no-stream"] + if custom_database_path: + args2.extend(["--database", str(custom_db_path)]) + result2 = runner.invoke(cli, args2, catch_exceptions=False) + assert result2.exit_code == 0, result2.output + assert result2.output == "Terry\n" + + if custom_database_path: + assert custom_db_path.exists() + db_path = str(custom_db_path) + else: + assert (user_path / "logs.db").exists() + db_path = str(user_path / "logs.db") + assert sqlite_utils.Database(db_path)["responses"].count == 2 + + +def test_default_exports(): + "Check key exports in the llm __all__ list" + for name in ("Model", "AsyncModel", "get_model", "get_async_model", "schema_dsl"): + assert name in llm.__all__, f"{name} not in llm.__all__" diff --git a/tests/test_llm_logs.py b/tests/test_llm_logs.py new file mode 100644 index 0000000..566d5a7 --- /dev/null +++ b/tests/test_llm_logs.py @@ -0,0 +1,1184 @@ +from click.testing import CliRunner +from llm.cli import cli +from llm.migrations import migrate +from llm.utils import monotonic_ulid +from llm import Fragment +import datetime +import json +import pathlib +import pytest +import re +import sqlite_utils +import sys +import textwrap +import time +from ulid import ULID +import yaml + +SINGLE_ID = "5843577700ba729bb14c327b30441885" +MULTI_ID = "4860edd987df587d042a9eb2b299ce5c" + + +@pytest.fixture +def log_path(user_path): + log_path = str(user_path / "logs.db") + db = sqlite_utils.Database(log_path) + migrate(db) + start = datetime.datetime.now(datetime.timezone.utc) + db["responses"].insert_all( + { + "id": str(monotonic_ulid()).lower(), + "system": "system", + "prompt": "prompt", + "response": 'response\n```python\nprint("hello word")\n```', + "model": "davinci", + "datetime_utc": (start + datetime.timedelta(seconds=i)).isoformat(), + "conversation_id": "abc123", + "input_tokens": 2, + "output_tokens": 5, + } + for i in range(100) + ) + return log_path + + +@pytest.fixture +def schema_log_path(user_path): + log_path = str(user_path / "logs_schema.db") + db = sqlite_utils.Database(log_path) + migrate(db) + start = datetime.datetime.now(datetime.timezone.utc) + db["schemas"].insert({"id": SINGLE_ID, "content": '{"name": "string"}'}) + db["schemas"].insert({"id": MULTI_ID, "content": '{"name": "array"}'}) + for i in range(2): + db["responses"].insert( + { + "id": str(ULID.from_timestamp(time.time() + i)).lower(), + "system": "system", + "prompt": "prompt", + "response": '{"name": "' + str(i) + '"}', + "model": "davinci", + "datetime_utc": (start + datetime.timedelta(seconds=i)).isoformat(), + "conversation_id": "abc123", + "input_tokens": 2, + "output_tokens": 5, + "schema_id": SINGLE_ID, + } + ) + for j in range(4): + db["responses"].insert( + { + "id": str(ULID.from_timestamp(time.time() + j)).lower(), + "system": "system", + "prompt": "prompt", + "response": '{"items": [{"name": "one"}, {"name": "two"}]}', + "model": "davinci", + "datetime_utc": (start + datetime.timedelta(seconds=i)).isoformat(), + "conversation_id": "abc456", + "input_tokens": 2, + "output_tokens": 5, + "schema_id": MULTI_ID, + } + ) + + return log_path + + +datetime_re = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") +id_re = re.compile(r"id: \w+") + + +@pytest.mark.parametrize("usage", (False, True)) +def test_logs_text(log_path, usage): + runner = CliRunner() + args = ["logs", "-p", str(log_path)] + if usage: + args.append("-u") + result = runner.invoke(cli, args, catch_exceptions=False) + assert result.exit_code == 0 + output = result.output + # Replace 2023-08-17T20:53:58 with YYYY-MM-DDTHH:MM:SS + output = datetime_re.sub("YYYY-MM-DDTHH:MM:SS", output) + # Replace id: whatever with id: xxx + output = id_re.sub("id: xxx", output) + expected = ( + ( + "# YYYY-MM-DDTHH:MM:SS conversation: abc123 id: xxx\n\n" + "Model: **davinci**\n\n" + "## Prompt\n\n" + "prompt\n\n" + "## System\n\n" + "system\n\n" + "## Response\n\n" + 'response\n```python\nprint("hello word")\n```\n\n' + ) + + ("## Token usage\n\n2 input, 5 output\n\n" if usage else "") + + ( + "# YYYY-MM-DDTHH:MM:SS conversation: abc123 id: xxx\n\n" + "Model: **davinci**\n\n" + "## Prompt\n\n" + "prompt\n\n" + "## Response\n\n" + 'response\n```python\nprint("hello word")\n```\n\n' + ) + + ("## Token usage\n\n2 input, 5 output\n\n" if usage else "") + + ( + "# YYYY-MM-DDTHH:MM:SS conversation: abc123 id: xxx\n\n" + "Model: **davinci**\n\n" + "## Prompt\n\n" + "prompt\n\n" + "## Response\n\n" + 'response\n```python\nprint("hello word")\n```\n\n' + ) + + ("## Token usage\n\n2 input, 5 output\n\n" if usage else "") + ) + assert output == expected + + +def test_logs_text_with_options(user_path): + """Test that ## Options section appears when options_json is set""" + log_path = str(user_path / "logs_with_options.db") + db = sqlite_utils.Database(log_path) + migrate(db) + start = datetime.datetime.now(datetime.timezone.utc) + + # Create response with options + db["responses"].insert( + { + "id": str(monotonic_ulid()).lower(), + "system": "system", + "prompt": "prompt", + "response": "response", + "model": "davinci", + "datetime_utc": start.isoformat(), + "conversation_id": "abc123", + "input_tokens": 2, + "output_tokens": 5, + "options_json": json.dumps( + {"thinking_level": "high", "media_resolution": "low"} + ), + } + ) + + runner = CliRunner() + result = runner.invoke(cli, ["logs", "-p", str(log_path)], catch_exceptions=False) + assert result.exit_code == 0 + output = result.output + + # Verify ## Options section is present + assert "## Options\n\n" in output + assert "- thinking_level: high" in output + assert "- media_resolution: low" in output + + +def test_logs_token_usage_details_are_markdown_code(user_path): + log_path = str(user_path / "logs_token_details.db") + db = sqlite_utils.Database(log_path) + migrate(db) + db["responses"].insert( + { + "id": str(monotonic_ulid()).lower(), + "system": None, + "prompt": "prompt", + "response": "response", + "model": "davinci", + "datetime_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "conversation_id": "abc123", + "input_tokens": 2, + "output_tokens": 5, + "token_details": json.dumps( + { + "output_tokens_details": { + "reasoning_tokens": 1, + "label": "`reasoning`", + } + } + ), + } + ) + + runner = CliRunner() + result = runner.invoke(cli, ["logs", "-p", log_path, "-u"], catch_exceptions=False) + assert result.exit_code == 0 + assert ( + '## Token usage\n\n2 input, 5 output, ``{"output_tokens_details": ' + '{"reasoning_tokens": 1, "label": "`reasoning`"}}``\n' + ) in result.output + + +@pytest.mark.parametrize("n", (None, 0, 2)) +def test_logs_json(n, log_path): + "Test that logs command correctly returns requested -n records" + runner = CliRunner() + args = ["logs", "-p", str(log_path), "--json"] + if n is not None: + args.extend(["-n", str(n)]) + result = runner.invoke(cli, args, catch_exceptions=False) + assert result.exit_code == 0 + logs = json.loads(result.output) + expected_length = 3 + if n is not None: + if n == 0: + expected_length = 100 + else: + expected_length = n + assert len(logs) == expected_length + + +@pytest.mark.parametrize( + "args", (["-r"], ["--response"], ["list", "-r"], ["list", "--response"]) +) +def test_logs_response_only(args, log_path): + "Test that logs -r/--response returns just the last response" + runner = CliRunner() + result = runner.invoke(cli, ["logs"] + args, catch_exceptions=False) + assert result.exit_code == 0 + assert result.output == 'response\n```python\nprint("hello word")\n```\n' + + +@pytest.mark.parametrize( + "args", + ( + ["-x"], + ["--extract"], + ["list", "-x"], + ["list", "--extract"], + # Using -xr together should have same effect as just -x + ["-xr"], + ["-x", "-r"], + ["--extract", "--response"], + ), +) +def test_logs_extract_first_code(args, log_path): + "Test that logs -x/--extract returns the first code block" + runner = CliRunner() + result = runner.invoke(cli, ["logs"] + args, catch_exceptions=False) + assert result.exit_code == 0 + assert result.output == 'print("hello word")\n\n' + + +@pytest.mark.parametrize( + "args", + ( + ["--xl"], + ["--extract-last"], + ["list", "--xl"], + ["list", "--extract-last"], + ["--xl", "-r"], + ["-x", "--xl"], + ), +) +def test_logs_extract_last_code(args, log_path): + "Test that logs --xl/--extract-last returns the last code block" + runner = CliRunner() + result = runner.invoke(cli, ["logs"] + args, catch_exceptions=False) + assert result.exit_code == 0 + assert result.output == 'print("hello word")\n\n' + + +@pytest.mark.parametrize("arg", ("-s", "--short")) +@pytest.mark.parametrize("usage", (None, "-u", "--usage")) +def test_logs_short(log_path, arg, usage): + runner = CliRunner() + args = ["logs", arg, "-p", str(log_path)] + if usage: + args.append(usage) + result = runner.invoke(cli, args) + assert result.exit_code == 0 + output = datetime_re.sub("YYYY-MM-DDTHH:MM:SS", result.output) + expected_usage = "" + if usage: + expected_usage = " usage:\n input: 2\n output: 5\n" + expected = ( + "- model: davinci\n" + " datetime: 'YYYY-MM-DDTHH:MM:SS'\n" + " conversation: abc123\n" + " system: system\n" + " prompt: prompt\n" + " prompt_fragments: []\n" + f" system_fragments: []\n{expected_usage}" + "- model: davinci\n" + " datetime: 'YYYY-MM-DDTHH:MM:SS'\n" + " conversation: abc123\n" + " system: system\n" + " prompt: prompt\n" + " prompt_fragments: []\n" + f" system_fragments: []\n{expected_usage}" + "- model: davinci\n" + " datetime: 'YYYY-MM-DDTHH:MM:SS'\n" + " conversation: abc123\n" + " system: system\n" + " prompt: prompt\n" + " prompt_fragments: []\n" + f" system_fragments: []\n{expected_usage}" + ) + assert output == expected + + +@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") +@pytest.mark.parametrize("env", ({}, {"LLM_USER_PATH": "/tmp/llm-user-path"})) +def test_logs_path(monkeypatch, env, user_path): + for key, value in env.items(): + monkeypatch.setenv(key, value) + runner = CliRunner() + result = runner.invoke(cli, ["logs", "path"]) + assert result.exit_code == 0 + if env: + expected = env["LLM_USER_PATH"] + "/logs.db" + else: + expected = str(user_path) + "/logs.db" + assert result.output.strip() == expected + + +@pytest.mark.parametrize("model", ("davinci", "curie")) +@pytest.mark.parametrize("path_option", (None, "-p", "--path", "-d", "--database")) +def test_logs_filtered(user_path, model, path_option): + log_path = str(user_path / "logs.db") + if path_option: + log_path = str(user_path / "logs_alternative.db") + db = sqlite_utils.Database(log_path) + migrate(db) + db["responses"].insert_all( + { + "id": str(monotonic_ulid()).lower(), + "system": "system", + "prompt": "prompt", + "response": "response", + "model": "davinci" if i % 2 == 0 else "curie", + } + for i in range(100) + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["logs", "list", "-m", model, "--json"] + + ([path_option, log_path] if path_option else []), + ) + assert result.exit_code == 0 + records = json.loads(result.output.strip()) + assert all(record["model"] == model for record in records) + + +@pytest.mark.parametrize( + "query,extra_args,expected", + ( + # With no search term order should be by datetime + ("", [], ["doc1", "doc2", "doc3"]), + # With a search it's order by rank instead + ("llama", [], ["doc1", "doc3"]), + ("alpaca", [], ["doc2"]), + # Model filter should work too + ("llama", ["-m", "davinci"], ["doc1", "doc3"]), + ("llama", ["-m", "davinci2"], []), + # Adding -l/--latest should return latest first (order by id desc) + ("llama", [], ["doc1", "doc3"]), + ("llama", ["-l"], ["doc3", "doc1"]), + ("llama", ["--latest"], ["doc3", "doc1"]), + ), +) +def test_logs_search(user_path, query, extra_args, expected): + log_path = str(user_path / "logs.db") + db = sqlite_utils.Database(log_path) + migrate(db) + + def _insert(id, text): + db["responses"].insert( + { + "id": id, + "system": "system", + "prompt": text, + "response": "response", + "model": "davinci", + } + ) + + _insert("doc1", "llama") + _insert("doc2", "alpaca") + _insert("doc3", "llama llama") + runner = CliRunner() + result = runner.invoke(cli, ["logs", "list", "-q", query, "--json"] + extra_args) + assert result.exit_code == 0 + records = json.loads(result.output.strip()) + assert [record["id"] for record in records] == expected + + +@pytest.mark.parametrize( + "args,expected", + ( + (["--data", "--schema", SINGLE_ID], '{"name": "1"}\n{"name": "0"}\n'), + ( + ["--data", "--schema", MULTI_ID], + ( + '{"items": [{"name": "one"}, {"name": "two"}]}\n' + '{"items": [{"name": "one"}, {"name": "two"}]}\n' + '{"items": [{"name": "one"}, {"name": "two"}]}\n' + '{"items": [{"name": "one"}, {"name": "two"}]}\n' + ), + ), + ( + ["--data-array", "--schema", MULTI_ID], + ( + '[{"items": [{"name": "one"}, {"name": "two"}]},\n' + ' {"items": [{"name": "one"}, {"name": "two"}]},\n' + ' {"items": [{"name": "one"}, {"name": "two"}]},\n' + ' {"items": [{"name": "one"}, {"name": "two"}]}]\n' + ), + ), + ( + ["--schema", MULTI_ID, "--data-key", "items"], + ( + '{"name": "one"}\n' + '{"name": "two"}\n' + '{"name": "one"}\n' + '{"name": "two"}\n' + '{"name": "one"}\n' + '{"name": "two"}\n' + '{"name": "one"}\n' + '{"name": "two"}\n' + ), + ), + ), +) +def test_logs_schema(schema_log_path, args, expected): + runner = CliRunner() + result = runner.invoke( + cli, + ["logs", "-n", "0", "-p", str(schema_log_path)] + args, + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert result.output == expected + + +def test_logs_schema_data_ids(schema_log_path): + db = sqlite_utils.Database(schema_log_path) + ulid = ULID.from_timestamp(time.time() + 100) + db["responses"].insert( + { + "id": str(ulid).lower(), + "system": "system", + "prompt": "prompt", + "response": json.dumps( + { + "name": "three", + "response_id": 1, + "conversation_id": 2, + "conversation_id_": 3, + } + ), + "model": "davinci", + "datetime_utc": ulid.datetime.isoformat(), + "conversation_id": "abc123", + "input_tokens": 2, + "output_tokens": 5, + "schema_id": SINGLE_ID, + } + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "logs", + "-n", + "0", + "-p", + str(schema_log_path), + "--data-ids", + "--data-key", + "items", + "--data-array", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + rows = json.loads(result.output) + last_row = rows.pop(-1) + assert set(last_row.keys()) == { + "conversation_id_", + "conversation_id", + "response_id", + "response_id_", + "name", + "conversation_id__", + } + for row in rows: + assert set(row.keys()) == {"conversation_id", "response_id", "name"} + + +_expected_yaml_re = r"""- id: [a-f0-9]{32} + summary: \| + + usage: \| + 4 times, most recently \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+00:00 +- id: [a-f0-9]{32} + summary: \| + + usage: \| + 2 times, most recently \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+00:00""" + + +@pytest.mark.parametrize( + "args,expected", + ( + (["schemas"], _expected_yaml_re), + (["schemas", "list"], _expected_yaml_re), + ), +) +def test_schemas_list_yaml(schema_log_path, args, expected): + result = CliRunner().invoke(cli, args + ["-d", str(schema_log_path)]) + assert result.exit_code == 0 + assert re.match(expected, result.output.strip()) + + +@pytest.mark.parametrize("is_nl", (False, True)) +def test_schemas_list_json(schema_log_path, is_nl): + result = CliRunner().invoke( + cli, + ["schemas", "list"] + + (["--nl"] if is_nl else ["--json"]) + + ["-d", str(schema_log_path)], + ) + assert result.exit_code == 0 + if is_nl: + rows = [json.loads(line) for line in result.output.strip().split("\n")] + else: + rows = json.loads(result.output) + assert len(rows) == 2 + assert rows[0]["content"] == {"name": "array"} + assert rows[0]["times_used"] == 4 + assert rows[1]["content"] == {"name": "string"} + assert rows[1]["times_used"] == 2 + assert set(rows[0].keys()) == {"id", "content", "recently_used", "times_used"} + + +@pytest.fixture +def fragments_fixture(user_path): + log_path = str(user_path / "logs_fragments.db") + db = sqlite_utils.Database(log_path) + migrate(db) + start = datetime.datetime.now(datetime.timezone.utc) + # Replace everything from here on + + fragment_hashes_by_slug = {} + # Create fragments + for i in range(1, 6): + content = f"This is fragment {i}" * (100 if i == 5 else 1) + fragment = Fragment(content, "fragment") + db["fragments"].insert( + { + "id": i, + "hash": fragment.id(), + # 5 is a long one: + "content": content, + "datetime_utc": start.isoformat(), + } + ) + db["fragment_aliases"].insert({"alias": f"hash{i}", "fragment_id": i}) + fragment_hashes_by_slug[f"hash{i}"] = fragment.id() + + # Create some more fragment aliases + db["fragment_aliases"].insert({"alias": "alias_1", "fragment_id": 3}) + db["fragment_aliases"].insert({"alias": "alias_3", "fragment_id": 4}) + db["fragment_aliases"].insert({"alias": "long_5", "fragment_id": 5}) + + def make_response(name, prompt_fragment_ids=None, system_fragment_ids=None): + time.sleep(0.05) # To ensure ULIDs order predictably + response_id = str(ULID.from_timestamp(time.time())).lower() + db["responses"].insert( + { + "id": response_id, + "system": f"system: {name}", + "prompt": f"prompt: {name}", + "response": f"response: {name}", + "model": "davinci", + "datetime_utc": start.isoformat(), + "conversation_id": "abc123", + "input_tokens": 2, + "output_tokens": 5, + } + ) + # Link fragments to this response + for fragment_id in prompt_fragment_ids or []: + db["prompt_fragments"].insert( + {"response_id": response_id, "fragment_id": fragment_id} + ) + for fragment_id in system_fragment_ids or []: + db["system_fragments"].insert( + {"response_id": response_id, "fragment_id": fragment_id} + ) + return {name: response_id} + + collected = {} + collected.update(make_response("no_fragments")) + collected.update( + single_prompt_fragment_id=make_response("single_prompt_fragment", [1]) + ) + collected.update( + single_system_fragment_id=make_response("single_system_fragment", None, [2]) + ) + collected.update( + multi_prompt_fragment_id=make_response("multi_prompt_fragment", [1, 2]) + ) + collected.update( + multi_system_fragment_id=make_response("multi_system_fragment", None, [1, 2]) + ) + collected.update(both_fragments_id=make_response("both_fragments", [1, 2], [3, 4])) + collected.update( + single_long_prompt_fragment_with_alias_id=make_response( + "single_long_prompt_fragment_with_alias", [5], None + ) + ) + collected.update( + single_system_fragment_with_alias_id=make_response( + "single_system_fragment_with_alias", None, [4] + ) + ) + return { + "path": log_path, + "fragment_hashes_by_slug": fragment_hashes_by_slug, + "collected": collected, + } + + +@pytest.mark.parametrize( + "fragment_refs,expected", + ( + ( + ["hash1"], + [ + { + "name": "single_prompt_fragment", + "prompt_fragments": ["hash1"], + "system_fragments": [], + }, + { + "name": "multi_prompt_fragment", + "prompt_fragments": ["hash1", "hash2"], + "system_fragments": [], + }, + { + "name": "multi_system_fragment", + "prompt_fragments": [], + "system_fragments": ["hash1", "hash2"], + }, + { + "name": "both_fragments", + "prompt_fragments": ["hash1", "hash2"], + "system_fragments": ["hash3", "hash4"], + }, + ], + ), + ( + ["alias_3"], + [ + { + "name": "both_fragments", + "prompt_fragments": ["hash1", "hash2"], + "system_fragments": ["hash3", "hash4"], + }, + { + "name": "single_system_fragment_with_alias", + "prompt_fragments": [], + "system_fragments": ["hash4"], + }, + ], + ), + # Testing for AND condition + ( + ["hash1", "hash4"], + [ + { + "name": "both_fragments", + "prompt_fragments": ["hash1", "hash2"], + "system_fragments": ["hash3", "hash4"], + }, + ], + ), + ), +) +def test_logs_fragments(fragments_fixture, fragment_refs, expected): + fragments_log_path = fragments_fixture["path"] + fragment_hashes_by_slug = fragments_fixture["fragment_hashes_by_slug"] + runner = CliRunner() + args = ["logs", "-d", fragments_log_path, "-n", "0"] + for ref in fragment_refs: + args.extend(["-f", ref]) + result = runner.invoke(cli, args + ["--json"], catch_exceptions=False) + assert result.exit_code == 0 + output = result.output + responses = json.loads(output) + # Re-shape that to same shape as expected + reshaped = [ + { + "name": response["prompt"].replace("prompt: ", ""), + "prompt_fragments": [ + fragment["hash"] for fragment in response["prompt_fragments"] + ], + "system_fragments": [ + fragment["hash"] for fragment in response["system_fragments"] + ], + } + for response in responses + ] + # Replace aliases with hash IDs in expected + for item in expected: + item["prompt_fragments"] = [ + fragment_hashes_by_slug.get(ref, ref) for ref in item["prompt_fragments"] + ] + item["system_fragments"] = [ + fragment_hashes_by_slug.get(ref, ref) for ref in item["system_fragments"] + ] + assert reshaped == expected + # Now test the `-s/--short` option: + result2 = runner.invoke(cli, args + ["-s"], catch_exceptions=False) + assert result2.exit_code == 0 + output2 = result2.output + loaded = yaml.safe_load(output2) + reshaped2 = [ + { + "name": item["prompt"].replace("prompt: ", ""), + "system_fragments": item["system_fragments"], + "prompt_fragments": item["prompt_fragments"], + } + for item in loaded + ] + assert reshaped2 == expected + + +def test_logs_fragments_markdown(fragments_fixture): + fragments_log_path = fragments_fixture["path"] + fragment_hashes_by_slug = fragments_fixture["fragment_hashes_by_slug"] + runner = CliRunner() + args = ["logs", "-d", fragments_log_path, "-n", "0"] + result = runner.invoke(cli, args, catch_exceptions=False) + assert result.exit_code == 0 + output = result.output + # Replace dates and IDs + output = datetime_re.sub("YYYY-MM-DDTHH:MM:SS", output) + output = id_re.sub("id: xxx", output) + expected_output = """ +# YYYY-MM-DDTHH:MM:SS conversation: abc123 id: xxx + +Model: **davinci** + +## Prompt + +prompt: no_fragments + +## System + +system: no_fragments + +## Response + +response: no_fragments + +# YYYY-MM-DDTHH:MM:SS conversation: abc123 id: xxx + +Model: **davinci** + +## Prompt + +prompt: single_prompt_fragment + +### Prompt fragments + +- hash1 + +## System + +system: single_prompt_fragment + +## Response + +response: single_prompt_fragment + +# YYYY-MM-DDTHH:MM:SS conversation: abc123 id: xxx + +Model: **davinci** + +## Prompt + +prompt: single_system_fragment + +## System + +system: single_system_fragment + +### System fragments + +- hash2 + +## Response + +response: single_system_fragment + +# YYYY-MM-DDTHH:MM:SS conversation: abc123 id: xxx + +Model: **davinci** + +## Prompt + +prompt: multi_prompt_fragment + +### Prompt fragments + +- hash1 +- hash2 + +## System + +system: multi_prompt_fragment + +## Response + +response: multi_prompt_fragment + +# YYYY-MM-DDTHH:MM:SS conversation: abc123 id: xxx + +Model: **davinci** + +## Prompt + +prompt: multi_system_fragment + +## System + +system: multi_system_fragment + +### System fragments + +- hash1 +- hash2 + +## Response + +response: multi_system_fragment + +# YYYY-MM-DDTHH:MM:SS conversation: abc123 id: xxx + +Model: **davinci** + +## Prompt + +prompt: both_fragments + +### Prompt fragments + +- hash1 +- hash2 + +## System + +system: both_fragments + +### System fragments + +- hash3 +- hash4 + +## Response + +response: both_fragments + +# YYYY-MM-DDTHH:MM:SS conversation: abc123 id: xxx + +Model: **davinci** + +## Prompt + +prompt: single_long_prompt_fragment_with_alias + +### Prompt fragments + +- hash5 + +## System + +system: single_long_prompt_fragment_with_alias + +## Response + +response: single_long_prompt_fragment_with_alias + +# YYYY-MM-DDTHH:MM:SS conversation: abc123 id: xxx + +Model: **davinci** + +## Prompt + +prompt: single_system_fragment_with_alias + +## System + +system: single_system_fragment_with_alias + +### System fragments + +- hash4 + +## Response + +response: single_system_fragment_with_alias + """ + # Replace hash4 etc with their proper IDs + for key, value in fragment_hashes_by_slug.items(): + expected_output = expected_output.replace(key, value) + assert output.strip() == expected_output.strip() + + +@pytest.mark.parametrize("arg", ("-e", "--expand")) +def test_expand_fragment_json(fragments_fixture, arg): + fragments_log_path = fragments_fixture["path"] + runner = CliRunner() + args = ["logs", "-d", fragments_log_path, "-f", "long_5", "--json"] + # Without -e the JSON is truncated + result = runner.invoke(cli, args, catch_exceptions=False) + assert result.exit_code == 0 + data = json.loads(result.output) + fragment = data[0]["prompt_fragments"][0]["content"] + assert fragment.startswith("This is fragment 5This is fragment 5") + assert len(fragment) < 200 + # With -e the JSON is expanded + result2 = runner.invoke(cli, args + [arg], catch_exceptions=False) + assert result2.exit_code == 0 + data2 = json.loads(result2.output) + fragment2 = data2[0]["prompt_fragments"][0]["content"] + assert fragment2.startswith("This is fragment 5This is fragment 5") + assert len(fragment2) > 200 + + +def test_expand_fragment_markdown(fragments_fixture): + fragments_log_path = fragments_fixture["path"] + fragment_hashes_by_slug = fragments_fixture["fragment_hashes_by_slug"] + runner = CliRunner() + args = ["logs", "-d", fragments_log_path, "-f", "long_5", "--expand"] + result = runner.invoke(cli, args, catch_exceptions=False) + assert result.exit_code == 0 + output = result.output + interesting_bit = ( + output.split("prompt: single_long_prompt_fragment_with_alias")[1] + .split("## System")[0] + .strip() + ) + hash = fragment_hashes_by_slug["hash5"] + expected_prefix = f"### Prompt fragments\n\n
{hash}\nThis is fragment 5" + assert interesting_bit.startswith(expected_prefix) + assert interesting_bit.endswith("
") + + +def test_logs_tools(logs_db): + runner = CliRunner() + code = textwrap.dedent(""" + def demo(): + return "one\\ntwo\\nthree" + """) + result1 = runner.invoke( + cli, + [ + "-m", + "echo", + "--functions", + code, + json.dumps({"tool_calls": [{"name": "demo"}]}), + ], + ) + assert result1.exit_code == 0 + result2 = runner.invoke(cli, ["logs", "-c"]) + normalized_output = re.sub(r"tc_[0-9a-z]{26}", "tc_TCID", result2.output) + assert ( + "### Tool results\n" + "\n" + "- **demo**: `tc_TCID`
\n" + " ```\n" + " one\n" + " two\n" + " three\n" + " ```\n" + "\n" + ) in normalized_output + # Log one that did NOT use tools, check that `llm logs --tools` ignores it + assert runner.invoke(cli, ["-m", "echo", "badger"]).exit_code == 0 + assert "badger" in runner.invoke(cli, ["logs"]).output + logs_tools_output = runner.invoke(cli, ["logs", "--tools"]).output + assert "badger" not in logs_tools_output + assert "three" in logs_tools_output + + +def test_logs_repeated_tools_use_short_hash(logs_db): + runner = CliRunner() + code = textwrap.dedent(""" + def demo(): + return "ok" + """) + args = [ + "-m", + "echo", + "--functions", + code, + json.dumps({"tool_calls": [{"name": "demo"}]}), + ] + result1 = runner.invoke(cli, args) + assert result1.exit_code == 0 + result2 = runner.invoke(cli, args) + assert result2.exit_code == 0 + + result3 = runner.invoke(cli, ["logs", "-n", "2"]) + assert result3.exit_code == 0 + tool_hashes = re.findall(r"- \*\*demo\*\*: `([0-9a-f]+)`", result3.output) + assert len(tool_hashes) == 2 + assert len(tool_hashes[0]) == 64 + assert tool_hashes[1] == tool_hashes[0][:7] + + +def test_logs_tool_call_argument_formatting(logs_db): + runner = CliRunner() + code = textwrap.dedent(""" + def demo(timeout: int, options: list): + return "ok" + """) + result1 = runner.invoke( + cli, + [ + "-m", + "echo", + "--functions", + code, + json.dumps( + { + "tool_calls": [ + { + "name": "demo", + "arguments": { + "timeout": 120, + "options": ["`tick`"], + }, + } + ] + } + ), + ], + ) + assert result1.exit_code == 0 + result2 = runner.invoke(cli, ["logs", "-c"]) + normalized_output = re.sub(r"tc_[0-9a-z]{26}", "tc_TCID", result2.output) + assert ( + "### Tool calls\n" + "\n" + "- **demo**: `tc_TCID`
\n" + " timeout: `120`\n" + ' options: ``["`tick`"]``\n' + ) in normalized_output + + +def test_logs_backup(logs_db): + assert not logs_db.tables + runner = CliRunner() + with runner.isolated_filesystem(): + runner.invoke(cli, ["-m", "echo", "simple prompt"]) + assert logs_db.tables + expected_path = pathlib.Path("backup.db") + assert not expected_path.exists() + # Now back it up + result = runner.invoke(cli, ["logs", "backup", "backup.db"]) + assert result.exit_code == 0 + assert result.output.startswith("Backed up ") + assert result.output.endswith("to backup.db\n") + assert expected_path.exists() + + +@pytest.mark.parametrize("async_", (False, True)) +def test_logs_resolved_model(logs_db, mock_model, async_mock_model, async_): + mock_model.resolved_model_name = "resolved-mock" + async_mock_model.resolved_model_name = "resolved-mock" + runner = CliRunner() + result = runner.invoke( + cli, ["-m", "mock", "simple prompt"] + (["--async"] if async_ else []) + ) + assert result.exit_code == 0 + # Should have logged the resolved model name + assert logs_db["responses"].count + response = list(logs_db["responses"].rows)[0] + assert response["model"] == "mock" + assert response["resolved_model"] == "resolved-mock" + + # Should show up in the JSON logs + result2 = runner.invoke(cli, ["logs", "--json"]) + assert result2.exit_code == 0 + logs = json.loads(result2.output.strip()) + assert len(logs) == 1 + assert logs[0]["model"] == "mock" + assert logs[0]["resolved_model"] == "resolved-mock" + + # And the rendered logs + result3 = runner.invoke(cli, ["logs"]) + assert "Model: **mock** (resolved: **resolved-mock**)" in result3.output + + +# ---- Reasoning persistence and markdown rendering ----------------- + + +def test_log_to_db_persists_visible_reasoning(logs_db, mock_model): + """A response that streams reasoning events should round-trip the + visible reasoning text via the new responses.reasoning column.""" + import llm + + mock_model.enqueue( + [ + llm.parts.StreamEvent(type="reasoning", chunk="thinking "), + llm.parts.StreamEvent(type="reasoning", chunk="hard"), + llm.parts.StreamEvent(type="text", chunk="hello"), + ] + ) + response = mock_model.prompt("hi") + response.text() + response.log_to_db(logs_db) + + row = next(logs_db["responses"].rows) + assert row["response"] == "hello" + assert row["reasoning"] == "thinking hard" + + +def test_log_to_db_persists_empty_reasoning_when_absent(logs_db, mock_model): + """No reasoning emitted → empty/null reasoning column, never raises.""" + mock_model.enqueue(["just text"]) + response = mock_model.prompt("hi") + response.text() + response.log_to_db(logs_db) + row = next(logs_db["responses"].rows) + assert not row.get("reasoning") + + +def test_logs_markdown_renders_reasoning_heading(user_path): + """When a row has reasoning text, `llm logs` renders a `## Reasoning` + heading between System and Response.""" + log_path = str(user_path / "logs_with_reasoning.db") + db = sqlite_utils.Database(log_path) + migrate(db) + db["responses"].insert( + { + "id": str(monotonic_ulid()).lower(), + "system": None, + "prompt": "hi", + "response": "answer", + "reasoning": "I thought hard about it.\n\n\n", + "model": "mock", + "datetime_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "conversation_id": "c1", + } + ) + runner = CliRunner() + result = runner.invoke(cli, ["logs", "-p", log_path], catch_exceptions=False) + assert result.exit_code == 0 + # rstrip() before rendering so trailing newlines from the + # provider output don't push `## Response` down the page. + assert "## Reasoning\n\nI thought hard about it.\n\n## Response" in result.output + + +def test_logs_markdown_omits_reasoning_heading_when_empty(log_path): + """When reasoning is empty/null, no heading appears (existing + fixture rows have no reasoning).""" + runner = CliRunner() + result = runner.invoke(cli, ["logs", "-p", str(log_path)], catch_exceptions=False) + assert result.exit_code == 0 + assert "## Reasoning" not in result.output diff --git a/tests/test_migrate.py b/tests/test_migrate.py new file mode 100644 index 0000000..7050211 --- /dev/null +++ b/tests/test_migrate.py @@ -0,0 +1,162 @@ +import llm +from llm.migrations import migrate +from llm.embeddings_migrations import embeddings_migrations +import pytest +import sqlite_utils + +EXPECTED = { + "id": str, + "model": str, + "resolved_model": str, + "prompt": str, + "system": str, + "prompt_json": str, + "options_json": str, + "response": str, + "response_json": str, + "conversation_id": str, + "duration_ms": int, + "datetime_utc": str, + "input_tokens": int, + "output_tokens": int, + "token_details": str, + "schema_id": str, + "reasoning": str, +} + + +def test_migrate_blank(): + db = sqlite_utils.Database(memory=True) + migrate(db) + assert set(db.table_names()).issuperset( + {"_llm_migrations", "conversations", "responses", "responses_fts"} + ) + assert db["responses"].columns_dict == EXPECTED + + foreign_keys = db["responses"].foreign_keys + for expected_fk in ( + sqlite_utils.db.ForeignKey( + table="responses", + column="conversation_id", + other_table="conversations", + other_column="id", + ), + ): + assert expected_fk in foreign_keys + + # Should have FTS configured with triggers on correct tables + assert {trigger.name for trigger in db.triggers} == { + "responses_ai", + "responses_ad", + "responses_au", + } + + +@pytest.mark.parametrize("has_record", [True, False]) +def test_migrate_from_original_schema(has_record): + db = sqlite_utils.Database(memory=True) + if has_record: + db["log"].insert( + { + "provider": "provider", + "system": "system", + "prompt": "prompt", + "chat_id": None, + "response": "response", + "model": "model", + "timestamp": "timestamp", + }, + ) + else: + # Create empty logs table + db["log"].create( + { + "provider": str, + "system": str, + "prompt": str, + "chat_id": str, + "response": str, + "model": str, + "timestamp": str, + } + ) + migrate(db) + expected_tables = {"_llm_migrations", "conversations", "responses", "responses_fts"} + if has_record: + expected_tables.add("logs") + assert set(db.table_names()).issuperset(expected_tables) + assert {trigger.name for trigger in db.triggers} == { + "responses_ai", + "responses_ad", + "responses_au", + } + + +def test_migrations_with_legacy_alter_table(): + # https://github.com/simonw/llm/issues/162 + db = sqlite_utils.Database(memory=True) + db.execute("pragma legacy_alter_table=on") + migrate(db) + + +def test_migrations_for_embeddings(): + db = sqlite_utils.Database(memory=True) + embeddings_migrations.apply(db) + assert db["collections"].columns_dict == {"id": int, "name": str, "model": str} + assert db["embeddings"].columns_dict == { + "collection_id": int, + "id": str, + "embedding": bytes, + "content": str, + "content_blob": bytes, + "content_hash": bytes, + "metadata": str, + "updated": int, + } + assert db["embeddings"].foreign_keys[0].column == "collection_id" + assert db["embeddings"].foreign_keys[0].other_table == "collections" + + +def test_backfill_content_hash(): + db = sqlite_utils.Database(memory=True) + # Run migrations up to but not including m004_store_content_hash + embeddings_migrations.apply(db, stop_before="m004_store_content_hash") + assert "content_hash" not in db["embeddings"].columns_dict + # Add some some directly directly because llm.Collection would run migrations + db["embeddings"].insert_all( + [ + { + "collection_id": 1, + "id": "1", + "embedding": ( + b"\x00\x00\xa0@\x00\x00\xa0@\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + ), + "content": None, + "metadata": None, + "updated": 1693763088, + }, + { + "collection_id": 1, + "id": "2", + "embedding": ( + b"\x00\x00\xe0@\x00\x00\xa0@\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + ), + "content": "goodbye world", + "metadata": None, + "updated": 1693763088, + }, + ] + ) + # Now finish the migrations + embeddings_migrations.apply(db) + row1, row2 = db["embeddings"].rows + # This one should be random: + assert row1["content_hash"] is not None + # This should be a hash of 'goodbye world' + assert row2["content_hash"] == llm.Collection.content_hash("goodbye world") diff --git a/tests/test_openai_messages.py b/tests/test_openai_messages.py new file mode 100644 index 0000000..9d2ae91 --- /dev/null +++ b/tests/test_openai_messages.py @@ -0,0 +1,613 @@ +import json + +import pytest +from pytest_httpx import IteratorStream + +import llm +from llm.default_plugins.openai_models import Chat +from llm.models import Prompt + +API_KEY = "badkey" + + +def _sse(delta, finish_reason=None, usage=None, tool_calls=None): + chunk = { + "id": "c1", + "object": "chat.completion.chunk", + "created": 1700000000, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}], + } + if tool_calls is not None: + chunk["choices"][0]["delta"]["tool_calls"] = tool_calls + if usage is not None: + chunk["usage"] = usage + return f"data: {json.dumps(chunk)}\n\n".encode("utf-8") + + +def _text_stream(): + yield _sse({"role": "assistant", "content": ""}) + yield _sse({"content": "Hel"}) + yield _sse({"content": "lo"}) + yield _sse({}, finish_reason="stop") + yield b"data: [DONE]\n\n" + + +def _tool_call_stream(): + """Mimic an OpenAI stream with a tool call (no preceding text).""" + yield _sse({"role": "assistant", "content": None}) + yield _sse( + {}, + tool_calls=[ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": ""}, + } + ], + ) + yield _sse( + {}, + tool_calls=[ + { + "index": 0, + "function": {"arguments": '{"city":'}, + } + ], + ) + yield _sse( + {}, + tool_calls=[ + { + "index": 0, + "function": {"arguments": '"Paris"}'}, + } + ], + ) + yield _sse({}, finish_reason="tool_calls") + yield b"data: [DONE]\n\n" + + +def _text_then_tool_call_stream(): + """Text arrives first, then a tool call — the tool call must get + a part_index past the text so assembly doesn't mix families.""" + yield _sse({"role": "assistant", "content": ""}) + yield _sse({"content": "Looking up"}) + yield _sse( + {}, + tool_calls=[ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"c":1}'}, + } + ], + ) + yield _sse({}, finish_reason="tool_calls") + yield b"data: [DONE]\n\n" + + +@pytest.fixture +def chat_model(): + # A plain Chat instance with vision and tools enabled — enough + # capabilities for the Part subtypes we translate. + return Chat("gpt-4o-mini", vision=True, supports_tools=True) + + +class TestBuildMessagesFromExplicitMessages: + def test_single_user_message(self, chat_model): + prompt = Prompt(None, model=chat_model, messages=[llm.user("hi")]) + result = chat_model.build_messages(prompt, None) + assert result == [{"role": "user", "content": "hi"}] + + def test_system_plus_user(self, chat_model): + prompt = Prompt( + None, + model=chat_model, + messages=[llm.system("be brief"), llm.user("hi")], + ) + result = chat_model.build_messages(prompt, None) + assert result == [ + {"role": "system", "content": "be brief"}, + {"role": "user", "content": "hi"}, + ] + + def test_user_with_attachment(self, chat_model): + att = llm.Attachment(type="image/jpeg", url="http://example.com/cat.jpg") + prompt = Prompt( + None, + model=chat_model, + messages=[llm.user("describe", att)], + ) + result = chat_model.build_messages(prompt, None) + assert result == [ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe"}, + { + "type": "image_url", + "image_url": {"url": "http://example.com/cat.jpg"}, + }, + ], + } + ] + + def test_assistant_with_tool_call(self, chat_model): + tool_call = llm.parts.ToolCallPart( + name="search", + arguments={"q": "weather"}, + tool_call_id="c1", + ) + prompt = Prompt( + None, + model=chat_model, + messages=[ + llm.user("search weather"), + llm.assistant("on it", tool_call), + ], + ) + result = chat_model.build_messages(prompt, None) + assert result == [ + {"role": "user", "content": "search weather"}, + { + "role": "assistant", + "content": "on it", + "tool_calls": [ + { + "type": "function", + "id": "c1", + "function": { + "name": "search", + "arguments": json.dumps({"q": "weather"}), + }, + } + ], + }, + ] + + def test_assistant_tool_call_only_no_text(self, chat_model): + """When an assistant message has tool_calls but no text, OpenAI + expects content=null.""" + tool_call = llm.parts.ToolCallPart( + name="search", arguments={"q": "x"}, tool_call_id="c1" + ) + prompt = Prompt( + None, + model=chat_model, + messages=[llm.user("q"), llm.assistant(tool_call)], + ) + result = chat_model.build_messages(prompt, None) + assert result[1] == { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "type": "function", + "id": "c1", + "function": { + "name": "search", + "arguments": json.dumps({"q": "x"}), + }, + } + ], + } + + def test_tool_role_message_with_tool_result(self, chat_model): + tr = llm.parts.ToolResultPart(name="search", output="sunny", tool_call_id="c1") + prompt = Prompt( + None, + model=chat_model, + messages=[ + llm.user("q"), + llm.tool_message(tr), + ], + ) + result = chat_model.build_messages(prompt, None) + assert result == [ + {"role": "user", "content": "q"}, + {"role": "tool", "tool_call_id": "c1", "content": "sunny"}, + ] + + def test_multiple_tool_results_emit_multiple_messages(self, chat_model): + """Parallel tool results: one OpenAI 'tool' message per result.""" + a = llm.parts.ToolResultPart(name="t", output="A", tool_call_id="c1") + b = llm.parts.ToolResultPart(name="t", output="B", tool_call_id="c2") + prompt = Prompt( + None, + model=chat_model, + messages=[llm.user("q"), llm.tool_message(a, b)], + ) + result = chat_model.build_messages(prompt, None) + assert result == [ + {"role": "user", "content": "q"}, + {"role": "tool", "tool_call_id": "c1", "content": "A"}, + {"role": "tool", "tool_call_id": "c2", "content": "B"}, + ] + + +class TestBuildMessagesLegacyFieldsStillWork: + """prompt=, system=, attachments= keep working — they synthesize + messages via Prompt.messages before build_messages sees them.""" + + def test_prompt_only(self, chat_model): + prompt = Prompt("hi", model=chat_model) + result = chat_model.build_messages(prompt, None) + assert result == [{"role": "user", "content": "hi"}] + + def test_system_and_prompt(self, chat_model): + prompt = Prompt("hi", model=chat_model, system="be brief") + result = chat_model.build_messages(prompt, None) + assert result == [ + {"role": "system", "content": "be brief"}, + {"role": "user", "content": "hi"}, + ] + + def test_attachments(self, chat_model): + att = llm.Attachment(type="image/jpeg", url="http://example.com/a.jpg") + prompt = Prompt("look", model=chat_model, attachments=[att]) + result = chat_model.build_messages(prompt, None) + assert result == [ + { + "role": "user", + "content": [ + {"type": "text", "text": "look"}, + { + "type": "image_url", + "image_url": {"url": "http://example.com/a.jpg"}, + }, + ], + } + ] + + +class TestBuildMessagesSystemDedup: + """Explicit messages with repeated system messages dedupe + repeated unchanged systems; OpenAI accepts one.""" + + def test_same_system_not_repeated(self, chat_model): + prompt = Prompt( + None, + model=chat_model, + messages=[ + llm.system("be brief"), + llm.user("q1"), + llm.assistant("a1"), + llm.system("be brief"), + llm.user("q2"), + ], + ) + result = chat_model.build_messages(prompt, None) + system_msgs = [m for m in result if m["role"] == "system"] + assert len(system_msgs) == 1 + assert system_msgs[0]["content"] == "be brief" + + def test_system_change_emitted(self, chat_model): + prompt = Prompt( + None, + model=chat_model, + messages=[ + llm.system("be brief"), + llm.user("q1"), + llm.assistant("a1"), + llm.system("be expansive"), + llm.user("q2"), + ], + ) + result = chat_model.build_messages(prompt, None) + system_msgs = [m for m in result if m["role"] == "system"] + assert [m["content"] for m in system_msgs] == [ + "be brief", + "be expansive", + ] + + +class TestBuildMessagesConversationHistory: + def test_prior_turn_text_plus_current_user(self, chat_model): + new_prompt = Prompt( + None, + model=chat_model, + messages=[ + llm.user("what's 1+1?"), + llm.assistant("2"), + llm.user("what about 2+2?"), + ], + ) + result = chat_model.build_messages(new_prompt, None) + assert result == [ + {"role": "user", "content": "what's 1+1?"}, + {"role": "assistant", "content": "2"}, + {"role": "user", "content": "what about 2+2?"}, + ] + + def test_no_double_emission_from_conversation_prompt_flow( + self, chat_model, httpx_mock + ): + # Two staged responses so conv.prompt twice can complete. + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-4o-mini", + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + "choices": [ + { + "message": {"role": "assistant", "content": "A1"}, + "finish_reason": "stop", + } + ], + }, + headers={"Content-Type": "application/json"}, + ) + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-4o-mini", + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + "choices": [ + { + "message": {"role": "assistant", "content": "A2"}, + "finish_reason": "stop", + } + ], + }, + headers={"Content-Type": "application/json"}, + ) + + model = llm.get_model("gpt-4o-mini") + conv = model.conversation() + r1 = conv.prompt("Q1", key=API_KEY, stream=False) + r1.text() + r2 = conv.prompt("Q2", key=API_KEY, stream=False) + r2.text() + + # Inspect what was sent on the SECOND turn. + sent_body = json.loads(httpx_mock.get_requests()[-1].content) + sent_messages = sent_body["messages"] + # Exactly three: user(Q1), assistant(A1), user(Q2). + assert sent_messages == [ + {"role": "user", "content": "Q1"}, + {"role": "assistant", "content": "A1"}, + {"role": "user", "content": "Q2"}, + ] + + +class TestStreamingExecuteYieldsStreamEvents: + def test_text_stream_yields_text_events(self, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + stream=IteratorStream(_text_stream()), + headers={"Content-Type": "text/event-stream"}, + ) + model = llm.get_model("gpt-4o-mini") + response = model.prompt("hi", key=API_KEY) + events = list(response.stream_events()) + # At least one StreamEvent, all text, all at part_index=0. + assert events, "expected stream events" + assert all(isinstance(e, llm.parts.StreamEvent) for e in events) + assert all(e.type == "text" for e in events) + assert all(e.part_index == 0 for e in events) + # Text chunks concatenate to the expected full text. + assert "".join(e.chunk for e in events) == "Hello" + + def test_text_stream_plain_iteration_still_returns_strings(self, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + stream=IteratorStream(_text_stream()), + headers={"Content-Type": "text/event-stream"}, + ) + model = llm.get_model("gpt-4o-mini") + response = model.prompt("hi", key=API_KEY) + chunks = list(response) + assert all(isinstance(c, str) for c in chunks) + assert "".join(chunks) == "Hello" + + def test_text_stream_messages_assembled(self, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + stream=IteratorStream(_text_stream()), + headers={"Content-Type": "text/event-stream"}, + ) + model = llm.get_model("gpt-4o-mini") + response = model.prompt("hi", key=API_KEY) + response.text() + assert response.messages() == [ + llm.Message(role="assistant", parts=[llm.parts.TextPart(text="Hello")]) + ] + + def test_tool_call_stream_yields_name_and_args_events(self, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + stream=IteratorStream(_tool_call_stream()), + headers={"Content-Type": "text/event-stream"}, + ) + + def get_weather(city: str) -> str: + "Look up the weather." + return "sunny" + + model = llm.get_model("gpt-4o-mini") + response = model.prompt("weather?", tools=[get_weather], key=API_KEY) + events = list(response.stream_events()) + types = [e.type for e in events] + assert "tool_call_name" in types + assert "tool_call_args" in types + # Name event carries the tool_call_id and name. + name_ev = next(e for e in events if e.type == "tool_call_name") + assert name_ev.tool_call_id == "call_1" + assert name_ev.chunk == "get_weather" + # Args events share the same part_index and concatenate to + # valid JSON. + args_events = [e for e in events if e.type == "tool_call_args"] + assert all(e.part_index == name_ev.part_index for e in args_events) + assert json.loads("".join(e.chunk for e in args_events)) == {"city": "Paris"} + + def test_tool_call_registered_via_add_tool_call(self, httpx_mock): + """response.tool_calls() still works — chain/execute relies on it.""" + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + stream=IteratorStream(_tool_call_stream()), + headers={"Content-Type": "text/event-stream"}, + ) + + def get_weather(city: str) -> str: + "Look up the weather." + return "sunny" + + model = llm.get_model("gpt-4o-mini") + response = model.prompt("weather?", tools=[get_weather], key=API_KEY) + response.text() + tcs = response.tool_calls() + assert len(tcs) == 1 + assert tcs[0].name == "get_weather" + assert tcs[0].arguments == {"city": "Paris"} + assert tcs[0].tool_call_id == "call_1" + + def test_text_then_tool_call_part_index_advances(self, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + stream=IteratorStream(_text_then_tool_call_stream()), + headers={"Content-Type": "text/event-stream"}, + ) + + def get_weather(c: int) -> str: + "Weather." + return "sunny" + + model = llm.get_model("gpt-4o-mini") + response = model.prompt("q", tools=[get_weather], key=API_KEY) + response.text() + # After streaming, messages has both a TextPart and a ToolCallPart. + parts = response.messages()[0].parts + assert any(isinstance(p, llm.parts.TextPart) for p in parts) + assert any(isinstance(p, llm.parts.ToolCallPart) for p in parts) + text_part = next(p for p in parts if isinstance(p, llm.parts.TextPart)) + tc_part = next(p for p in parts if isinstance(p, llm.parts.ToolCallPart)) + assert text_part.text == "Looking up" + assert tc_part.name == "get_weather" + assert tc_part.arguments == {"c": 1} + + +class TestAsyncStreamingExecuteYieldsStreamEvents: + @pytest.mark.asyncio + async def test_text_stream_yields_text_events(self, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + stream=IteratorStream(_text_stream()), + headers={"Content-Type": "text/event-stream"}, + ) + model = llm.get_async_model("gpt-4o-mini") + response = model.prompt("hi", key=API_KEY) + events = [] + async for event in response.astream_events(): + events.append(event) + assert all(isinstance(e, llm.parts.StreamEvent) for e in events) + assert [e.type for e in events] == ["text"] * len(events) + assert "".join(e.chunk for e in events) == "Hello" + + +def _text_stream_with_reasoning_usage(reasoning_tokens): + """Stream with usage in the final chunk reporting reasoning_tokens.""" + yield _sse({"role": "assistant", "content": ""}) + yield _sse({"content": "Hel"}) + yield _sse({"content": "lo"}) + yield _sse({}, finish_reason="stop") + # Final chunk with usage — OpenAI streams usage once at the end + # when stream_options.include_usage=True. + yield _sse( + {}, + usage={ + "prompt_tokens": 5, + "completion_tokens": 2, + "total_tokens": 7, + "completion_tokens_details": {"reasoning_tokens": reasoning_tokens}, + }, + ) + yield b"data: [DONE]\n\n" + + +class TestReasoningTokenCount: + def test_redacted_reasoning_part_emitted_when_count_present(self, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + stream=IteratorStream(_text_stream_with_reasoning_usage(150)), + headers={"Content-Type": "text/event-stream"}, + ) + model = llm.get_model("gpt-4o-mini") + response = model.prompt("hi", key=API_KEY) + response.text() + assert response.messages() == [ + llm.Message( + role="assistant", + parts=[ + llm.parts.ReasoningPart(text="", redacted=True), + llm.parts.TextPart(text="Hello"), + ], + ) + ] + + def test_no_reasoning_part_when_zero_or_absent(self, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + stream=IteratorStream(_text_stream_with_reasoning_usage(0)), + headers={"Content-Type": "text/event-stream"}, + ) + model = llm.get_model("gpt-4o-mini") + response = model.prompt("hi", key=API_KEY) + response.text() + parts = response.messages()[0].parts + assert not any( + isinstance(p, llm.parts.ReasoningPart) for p in parts + ), "should not add a redacted reasoning part when count=0" + + +class TestNonStreamingExecuteYieldsStreamEvents: + def test_non_streaming_text_yields_single_event(self, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "model": "gpt-4o-mini", + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + "choices": [ + { + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop", + } + ], + }, + headers={"Content-Type": "application/json"}, + ) + model = llm.get_model("gpt-4o-mini") + response = model.prompt("hi", key=API_KEY, stream=False) + events = list(response.stream_events()) + assert events == [ + llm.parts.StreamEvent(type="text", chunk="Hello", part_index=0) + ] + assert response.messages() == [ + llm.Message(role="assistant", parts=[llm.parts.TextPart(text="Hello")]) + ] diff --git a/tests/test_openai_responses.py b/tests/test_openai_responses.py new file mode 100644 index 0000000..50b9815 --- /dev/null +++ b/tests/test_openai_responses.py @@ -0,0 +1,677 @@ +"""Tests for the /v1/responses code path in the default OpenAI plugin.""" + +import json +import os + +import llm +import pytest +from pytest_httpx import IteratorStream + +API_KEY = os.environ.get("PYTEST_OPENAI_API_KEY", None) or "badkey" + + +def _responses_sse(event_type, data): + data = {"type": event_type, **data} + return f"event: {event_type}\ndata: {json.dumps(data)}\n\n".encode("utf-8") + + +def _responses_reasoning_summary_stream(): + yield _responses_sse( + "response.reasoning_summary_text.delta", + { + "item_id": "rs_1", + "output_index": 0, + "summary_index": 0, + "delta": "Thinking", + "sequence_number": 1, + }, + ) + yield _responses_sse( + "response.reasoning_summary_text.delta", + { + "item_id": "rs_1", + "output_index": 0, + "summary_index": 0, + "delta": " aloud", + "sequence_number": 2, + }, + ) + yield _responses_sse( + "response.output_item.done", + { + "item": { + "id": "rs_1", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "Thinking aloud"}], + "encrypted_content": "encrypted", + "status": "completed", + }, + "output_index": 0, + "sequence_number": 3, + }, + ) + yield _responses_sse( + "response.output_text.delta", + { + "item_id": "msg_1", + "output_index": 1, + "content_index": 0, + "delta": "done", + "logprobs": [], + "sequence_number": 4, + }, + ) + + +def test_responses_model_is_registered(): + model = llm.get_model("gpt-5.5") + assert "Responses" in type(model).__name__ + # The chat_completions opt-out option must be exposed. + assert "chat_completions" in model.Options.model_fields + + +def test_chat_completions_opt_out_dispatches_to_chat(httpx_mock): + """When chat_completions=1 is passed, the request must hit + /v1/chat/completions, not /v1/responses.""" + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/chat/completions", + json={ + "id": "chatcmpl-x", + "object": "chat.completion", + "model": "gpt-5.5", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "hi from chat"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}, + }, + headers={"Content-Type": "application/json"}, + ) + model = llm.get_model("gpt-5.5") + response = model.prompt("hello", stream=False, chat_completions=True, key="test") + assert response.text() == "hi from chat" + + +def test_default_routes_to_responses_endpoint(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/responses", + json={ + "id": "resp_test_1", + "object": "response", + "created_at": 1, + "model": "gpt-5.5", + "output": [ + { + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "hi from responses", + "annotations": [], + } + ], + } + ], + "usage": { + "input_tokens": 5, + "output_tokens": 3, + "total_tokens": 8, + }, + "status": "completed", + }, + headers={"Content-Type": "application/json"}, + ) + model = llm.get_model("gpt-5.5") + response = model.prompt("hello", stream=False, key="test") + assert response.text() == "hi from responses" + # Ensure we sent to the right endpoint + requests = [r for r in httpx_mock.get_requests()] + assert any("/v1/responses" in str(r.url) for r in requests) + request_body = json.loads(requests[-1].content) + assert request_body["include"] == ["reasoning.encrypted_content"] + assert request_body["reasoning"] == {"summary": "auto"} + + +def test_hide_reasoning_omits_reasoning_summary_from_responses_request(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/responses", + json={ + "id": "resp_test_1", + "object": "response", + "created_at": 1, + "model": "gpt-5.5", + "output": [ + { + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "hidden", + "annotations": [], + } + ], + } + ], + "usage": { + "input_tokens": 5, + "output_tokens": 3, + "total_tokens": 8, + }, + "status": "completed", + }, + headers={"Content-Type": "application/json"}, + ) + model = llm.get_model("gpt-5.5") + response = model.prompt("hello", stream=False, key="test", hide_reasoning=True) + assert response.text() == "hidden" + request_body = json.loads(httpx_mock.get_requests()[-1].content) + assert request_body["include"] == ["reasoning.encrypted_content"] + assert "reasoning" not in request_body + + +def test_non_reasoning_responses_model_omits_encrypted_reasoning_include(httpx_mock): + from llm.default_plugins.openai_models import Responses + + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/responses", + json={ + "id": "resp_test_1", + "object": "response", + "created_at": 1, + "model": "gpt-4.1", + "output": [ + { + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "hi from gpt-4.1", + "annotations": [], + } + ], + } + ], + "usage": { + "input_tokens": 5, + "output_tokens": 3, + "total_tokens": 8, + }, + "status": "completed", + }, + headers={"Content-Type": "application/json"}, + ) + + model = Responses("gpt-4.1", vision=True, supports_schema=True, supports_tools=True) + response = model.prompt("hello", stream=False, key="test") + + assert response.text() == "hi from gpt-4.1" + request_body = json.loads(httpx_mock.get_requests()[-1].content) + assert request_body["model"] == "gpt-4.1" + assert "include" not in request_body + assert "reasoning" not in request_body + + +def test_responses_input_translation(): + """Unit-test the message-to-input translator without hitting the API.""" + from llm.parts import ( + Message, + TextPart, + ToolCallPart, + ToolResultPart, + ) + + model = llm.get_model("gpt-5.5") + + class FakePrompt: + messages = [ + Message(role="system", parts=[TextPart(text="be brief")]), + Message(role="user", parts=[TextPart(text="2 + 2?")]), + Message( + role="assistant", + parts=[ + ToolCallPart( + name="add", + arguments={"a": 2, "b": 2}, + tool_call_id="call_abc", + ) + ], + ), + Message( + role="tool", + parts=[ToolResultPart(name="add", output="4", tool_call_id="call_abc")], + ), + ] + + items, instructions = model._build_responses_input(FakePrompt()) + assert instructions == "be brief" + # First user message is a plain string content + assert items[0] == {"role": "user", "content": "2 + 2?"} + # function_call from assistant + assert items[1]["type"] == "function_call" + assert items[1]["call_id"] == "call_abc" + assert items[1]["name"] == "add" + assert json.loads(items[1]["arguments"]) == {"a": 2, "b": 2} + # tool result + assert items[2] == { + "type": "function_call_output", + "call_id": "call_abc", + "output": "4", + } + + +def test_responses_input_translation_assistant_text_uses_easy_input_message(): + """Plain prior assistant text should match OpenAI's EasyInputMessage shape.""" + from llm.parts import Message, TextPart + + model = llm.get_model("gpt-5.5") + + class FakePrompt: + messages = [ + Message(role="user", parts=[TextPart(text="hello")]), + Message(role="assistant", parts=[TextPart(text="first-ok")]), + Message(role="user", parts=[TextPart(text="what next?")]), + ] + + items, instructions = model._build_responses_input(FakePrompt()) + + assert instructions is None + assert items == [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "first-ok"}, + {"role": "user", "content": "what next?"}, + ] + + +def test_responses_reply_sends_prior_assistant_text_as_string(httpx_mock): + """response.reply() should send the same simple history shape a direct + openai-python Responses call would use for a text-only assistant turn.""" + + def response_json(response_id, message_id, text): + return { + "id": response_id, + "object": "response", + "created_at": 1, + "model": "gpt-5.5", + "output": [ + { + "type": "message", + "id": message_id, + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": text, + "annotations": [], + } + ], + } + ], + "usage": { + "input_tokens": 5, + "output_tokens": 3, + "total_tokens": 8, + }, + "status": "completed", + } + + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/responses", + json=response_json("resp_1", "msg_1", "first-ok"), + headers={"Content-Type": "application/json"}, + ) + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/responses", + json=response_json("resp_2", "msg_2", "followup-ok"), + headers={"Content-Type": "application/json"}, + ) + + model = llm.get_model("gpt-5.5") + first = model.prompt("Say exactly: first-ok", stream=False, key="test") + second = first.reply("Say exactly: followup-ok", stream=False, key="test") + + assert first.text() == "first-ok" + assert second.text() == "followup-ok" + requests = httpx_mock.get_requests() + second_body = json.loads(requests[-1].content) + assert second_body["input"] == [ + {"role": "user", "content": "Say exactly: first-ok"}, + {"role": "assistant", "content": "first-ok"}, + {"role": "user", "content": "Say exactly: followup-ok"}, + ] + + +def test_responses_kwargs_packs_reasoning_and_verbosity(): + model = llm.get_model("gpt-5.5") + options = model.Options(reasoning_effort="low", verbosity="low") + + class FakePrompt: + pass + + p = FakePrompt() + p.options = options + p.tools = [] + p.schema = None + kwargs = model._build_responses_kwargs(p, stream=False) + assert kwargs["reasoning"] == {"summary": "auto", "effort": "low"} + assert kwargs["text"]["verbosity"] == "low" + + +def test_responses_kwargs_sets_reasoning_summary_without_effort(): + model = llm.get_model("gpt-5.5") + options = model.Options() + + class FakePrompt: + pass + + p = FakePrompt() + p.options = options + p.tools = [] + p.schema = None + kwargs = model._build_responses_kwargs(p, stream=False) + assert kwargs["reasoning"] == {"summary": "auto"} + + +def test_responses_kwargs_omits_reasoning_summary_when_hide_reasoning(): + model = llm.get_model("gpt-5.5") + options = model.Options(reasoning_effort="low") + + class FakePrompt: + pass + + p = FakePrompt() + p.options = options + p.tools = [] + p.schema = None + p.hide_reasoning = True + kwargs = model._build_responses_kwargs(p, stream=False) + assert kwargs["reasoning"] == {"effort": "low"} + + +def test_responses_kwargs_omits_empty_reasoning_when_hide_reasoning(): + model = llm.get_model("gpt-5.5") + options = model.Options() + + class FakePrompt: + pass + + p = FakePrompt() + p.options = options + p.tools = [] + p.schema = None + p.hide_reasoning = True + kwargs = model._build_responses_kwargs(p, stream=False) + assert "reasoning" not in kwargs + + +def test_responses_streams_reasoning_summary_text(httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://api.openai.com/v1/responses", + stream=IteratorStream(_responses_reasoning_summary_stream()), + headers={"Content-Type": "text/event-stream"}, + ) + + model = llm.get_model("gpt-5.5") + response = model.prompt("hello", key="test") + events = list(response.stream_events()) + + assert [(e.type, e.chunk) for e in events] == [ + ("reasoning", "Thinking"), + ("reasoning", " aloud"), + ("reasoning", ""), + ("text", "done"), + ] + messages = response.messages() + reasoning_parts = [ + p for m in messages for p in m.parts if isinstance(p, llm.parts.ReasoningPart) + ] + assert reasoning_parts == [ + llm.parts.ReasoningPart( + text="Thinking aloud", + provider_metadata={ + "openai": { + "id": "rs_1", + "encrypted_content": "encrypted", + "summary": [{"type": "summary_text", "text": "Thinking aloud"}], + } + }, + ) + ] + assert response.text() == "done" + + +@pytest.mark.vcr +def test_responses_basic_non_streaming(vcr): + model = llm.get_model("gpt-5.5") + response = model.prompt( + "Reply with exactly: pong", + stream=False, + reasoning_effort="low", + key=API_KEY, + ) + text = response.text() + assert "pong" in text.lower() + # response_json should reflect the Responses API shape + assert response.response_json["object"] == "response" + + +@pytest.mark.vcr +def test_responses_basic_streaming(vcr): + model = llm.get_model("gpt-5.5") + response = model.prompt( + "Reply with exactly: pong", + reasoning_effort="low", + key=API_KEY, + ) + chunks = list(response) + text = "".join(chunks) + assert "pong" in text.lower() + + +@pytest.mark.vcr +def test_responses_tool_use(vcr): + model = llm.get_model("gpt-5.5") + + def multiply(a: int, b: int) -> int: + "Multiply two numbers." + return a * b + + chain = model.chain( + "What is 1231 * 2331? Use the multiply tool.", + tools=[multiply], + stream=False, + options={"reasoning_effort": "low"}, + key=API_KEY, + ) + output = chain.text() + assert "2869461" in output.replace(",", "") + first, second = chain._responses + assert first.tool_calls()[0].name == "multiply" + assert first.tool_calls()[0].arguments == {"a": 1231, "b": 2331} + assert second.prompt.tool_results[0].output == "2869461" + + +@pytest.mark.vcr +def test_responses_tool_use_streaming(vcr): + model = llm.get_model("gpt-5.5") + + def multiply(a: int, b: int) -> int: + "Multiply two numbers." + return a * b + + chain = model.chain( + "What is 1231 * 2331? Use the multiply tool.", + tools=[multiply], + options={"reasoning_effort": "low"}, + key=API_KEY, + ) + output = "".join(chain) + assert "2869461" in output.replace(",", "") + first, second = chain._responses + assert first.tool_calls()[0].arguments == {"a": 1231, "b": 2331} + + +@pytest.mark.vcr +def test_responses_round_trips_encrypted_reasoning(vcr): + """Reasoning items returned by the API in the first turn must be + echoed back verbatim on the second turn so the model can pick up + its hidden chain of thought after the tool result arrives.""" + from llm.parts import ReasoningPart + + model = llm.get_model("gpt-5.5") + + def lookup_population(country: str) -> int: + "Returns the current population of the specified fictional country." + return 123124 + + def can_have_dragons(population: int) -> bool: + "Returns True if the specified population can have dragons." + return population > 10000 + + chain = model.chain( + "Pick a clever country name, look up its population, then check " + "whether it can have dragons. Be brief.", + tools=[lookup_population, can_have_dragons], + stream=False, + options={"reasoning_effort": "high"}, + key=API_KEY, + ) + chain.text() # drain the chain + + first = chain._responses[0] + + # The first response must produce at least one ReasoningPart carrying + # the opaque encrypted_content + id. + reasoning_parts = [ + p for m in first.messages() for p in m.parts if isinstance(p, ReasoningPart) + ] + assert reasoning_parts, "first turn should expose at least one ReasoningPart" + pm = reasoning_parts[0].provider_metadata or {} + assert "openai" in pm + assert pm["openai"].get("encrypted_content"), "encrypted_content must be captured" + assert pm["openai"].get("id"), "reasoning id must be captured" + + # The second turn's outgoing input must echo back that reasoning + # item, otherwise the model loses its chain of thought. + second = chain._responses[1] + second_input = (second._prompt_json or {}).get("input") or [] + reasoning_inputs = [it for it in second_input if it.get("type") == "reasoning"] + assert reasoning_inputs, "second turn must echo a reasoning input item" + assert reasoning_inputs[0]["encrypted_content"] == pm["openai"]["encrypted_content"] + assert reasoning_inputs[0]["id"] == pm["openai"]["id"] + + +@pytest.mark.vcr +def test_responses_interleaved_reasoning_between_tool_calls(vcr): + """Tool calls during reasoning: each turn produces fresh reasoning AND + every prior reasoning block is round-tripped on every subsequent turn + so the model's hidden chain of thought accumulates across the whole + chain. This is the GPT-5-class capability that the Chat Completions + API can't deliver because it discards reasoning between turns.""" + from llm.parts import ReasoningPart + + model = llm.get_model("gpt-5.5") + + # Tool whose results force the model to re-plan between calls: each + # lookup hands the model a NEW key to use next, so the model has to + # think to figure out the next argument. Parallel tool calls would + # short-circuit this, so we need the model to reason in series. + def db_lookup(key: str) -> str: + "Look up a value by key in the puzzle database." + table = { + "start": "Begin with the value 7.", + "step1_7": "Multiply by 13. Now lookup with key step2_.", + "step2_91": "Subtract 11. Now lookup with key step3_.", + "step3_80": ("The answer is the value modulo 9. State only the integer."), + } + return table.get(key, "unknown key") + + conversation = model.conversation(tools=[db_lookup]) + conversation.chain_limit = 4 + chain = conversation.chain( + "Solve this puzzle: call db_lookup('start'), then follow each " + "instruction step by step. Each lookup tells you the next key " + "to use. Compute each step in your head. State only the final " + "integer.", + stream=False, + options={"reasoning_effort": "high"}, + key=API_KEY, + ) + # The chain may exceed the limit - we just want enough turns to + # observe interleaved reasoning, then we stop. + try: + chain.text() + except ValueError as e: + if "Chain limit" not in str(e): + raise + + responses = chain._responses + assert ( + len(responses) >= 3 + ), f"expected at least 3 chained turns, got {len(responses)}" + + # 1) Fresh reasoning happens on more than just the first turn. This is + # the actual interleaved-reasoning capability, not just round-trip. + reasoning_token_counts = [] + for r in responses: + u = r.usage() + details = (u.details if u else None) or {} + reasoning_token_counts.append( + (details.get("output_tokens_details") or {}).get("reasoning_tokens") or 0 + ) + turns_with_fresh_reasoning = sum(1 for n in reasoning_token_counts if n > 0) + assert turns_with_fresh_reasoning >= 2, ( + f"expected >=2 turns to produce fresh reasoning, got " + f"{turns_with_fresh_reasoning} (counts: {reasoning_token_counts})" + ) + + # 2) Every reasoning block produced earlier in the chain is round- + # tripped on every subsequent turn. The Nth turn's outgoing input + # must contain at least N-1 reasoning items. + for i in range(1, len(responses)): + outgoing = (responses[i]._prompt_json or {}).get("input") or [] + reasoning_count = sum(1 for it in outgoing if it.get("type") == "reasoning") + # encrypted_content + id are non-empty on each one + for it in outgoing: + if it.get("type") == "reasoning": + assert it.get("encrypted_content"), "encrypted_content lost" + assert it.get("id"), "reasoning id lost" + assert ( + reasoning_count >= i + ), f"turn {i} must echo >= {i} reasoning items, got {reasoning_count}" + + # 3) The captured ReasoningParts on the assistant messages carry the + # opaque metadata that was actually echoed back on the wire. + for i, r in enumerate(responses[:-1]): + rparts = [ + p for m in r.messages() for p in m.parts if isinstance(p, ReasoningPart) + ] + if reasoning_token_counts[i] > 0: + assert rparts, ( + f"turn {i} produced reasoning_tokens={reasoning_token_counts[i]} " + "but no ReasoningPart was persisted" + ) + for rp in rparts: + pm = (rp.provider_metadata or {}).get("openai") or {} + assert pm.get( + "encrypted_content" + ), "ReasoningPart missing encrypted_content" diff --git a/tests/test_options_parameter.py b/tests/test_options_parameter.py new file mode 100644 index 0000000..df4c09a --- /dev/null +++ b/tests/test_options_parameter.py @@ -0,0 +1,99 @@ +"""Tests for the `options=` parameter on `.prompt()` and `.reply()`. + +The `options={...}` dict form is the documented API; the `**kwargs` form +continues to work undocumented for backwards compatibility. +""" + +import pytest + + +def test_prompt_with_options_dict(mock_model): + mock_model.enqueue(["ok"]) + r = mock_model.prompt("q", options={"max_tokens": 42}) + r.text() + assert r.prompt.options.max_tokens == 42 + assert r.to_dict()["prompt"].get("options") == {"max_tokens": 42} + + +def test_prompt_kwargs_still_work(mock_model): + mock_model.enqueue(["ok"]) + r = mock_model.prompt("q", max_tokens=42) + r.text() + assert r.prompt.options.max_tokens == 42 + + +def test_prompt_options_and_kwargs_merge(mock_model): + # Non-overlapping keys merge cleanly — options= and kwargs both contribute + mock_model.Options.model_rebuild() + mock_model.enqueue(["ok"]) + # Only max_tokens exists on MockModel.Options — use it via options=. + # Pass an empty options dict alongside a kwarg to confirm both paths coexist. + r = mock_model.prompt("q", options={}, max_tokens=7) + r.text() + assert r.prompt.options.max_tokens == 7 + + +def test_prompt_options_and_kwargs_conflict_raises(mock_model): + mock_model.enqueue(["ok"]) + with pytest.raises(TypeError, match="both in options="): + mock_model.prompt("q", options={"max_tokens": 1}, max_tokens=2) + + +def test_conversation_prompt_with_options_dict(mock_model): + mock_model.enqueue(["ok"]) + convo = mock_model.conversation() + r = convo.prompt("q", options={"max_tokens": 99}) + r.text() + assert r.prompt.options.max_tokens == 99 + + +def test_response_reply_with_options_dict(mock_model): + mock_model.enqueue(["first"]) + mock_model.enqueue(["second"]) + r1 = mock_model.prompt("q1", options={"max_tokens": 5}) + r1.text() + r2 = r1.reply("q2", options={"max_tokens": 17}) + r2.text() + assert r2.prompt.options.max_tokens == 17 + + +def test_response_reply_kwargs_still_work(mock_model): + mock_model.enqueue(["first"]) + mock_model.enqueue(["second"]) + r1 = mock_model.prompt("q1", max_tokens=5) + r1.text() + r2 = r1.reply("q2", max_tokens=17) + r2.text() + assert r2.prompt.options.max_tokens == 17 + + +@pytest.mark.asyncio +async def test_async_prompt_with_options_dict(async_mock_model): + # AsyncMockModel inherits the empty base Options (extra="forbid"), + # so pass an empty options dict — this verifies the parameter is + # accepted and the empty-dict path works. + async_mock_model.enqueue(["ok"]) + r = await async_mock_model.prompt("q", options={}).text() + assert r == "ok" + + +@pytest.mark.asyncio +async def test_async_prompt_options_and_kwargs_conflict_raises(async_mock_model): + import llm + + # Build an async model with a real Option field so we can collide them. + class AsyncModelWithOption(llm.AsyncModel): + model_id = "async-with-option" + + class Options(llm.Options): + from typing import Optional as _Opt + from pydantic import Field as _Field + + max_tokens: _Opt[int] = _Field(default=None) + + async def execute(self, prompt, stream, response, conversation): + yield "ok" + + m = AsyncModelWithOption() + with pytest.raises(TypeError, match="both in options="): + await m.prompt("q", options={"max_tokens": 1}, max_tokens=2).text() diff --git a/tests/test_parts.py b/tests/test_parts.py new file mode 100644 index 0000000..f2a89bc --- /dev/null +++ b/tests/test_parts.py @@ -0,0 +1,2106 @@ +import json +import pytest +import llm + + +class TestTextPart: + def test_roundtrip(self): + part = llm.parts.TextPart(text="Hello world") + restored = llm.parts.Part.from_dict(part.to_dict()) + assert restored == part + assert isinstance(restored, llm.parts.TextPart) + assert restored.text == "Hello world" + + def test_to_dict_shape(self): + assert llm.parts.TextPart(text="hi").to_dict() == {"type": "text", "text": "hi"} + + def test_with_provider_metadata(self): + part = llm.parts.TextPart( + text="hi", provider_metadata={"openai": {"flag": True}} + ) + restored = llm.parts.Part.from_dict(part.to_dict()) + assert restored == part + + +class TestReasoningPart: + def test_roundtrip_with_text(self): + part = llm.parts.ReasoningPart(text="Let me think...") + restored = llm.parts.Part.from_dict(part.to_dict()) + assert restored == part + assert restored.text == "Let me think..." + assert restored.redacted is False + + def test_roundtrip_redacted(self): + part = llm.parts.ReasoningPart(text="", redacted=True) + d = part.to_dict() + assert d["redacted"] is True + assert "token_count" not in d + restored = llm.parts.Part.from_dict(d) + assert restored == part + + def test_no_token_count_field(self): + # token_count was removed: opaque token totals live on + # response.token_details, not on the Part. + with pytest.raises(TypeError): + llm.parts.ReasoningPart(text="", redacted=True, token_count=150) + + +class TestToolCallPart: + def test_roundtrip(self): + part = llm.parts.ToolCallPart( + name="search", + arguments={"query": "weather"}, + tool_call_id="call_123", + ) + restored = llm.parts.Part.from_dict(part.to_dict()) + assert restored == part + assert restored.server_executed is False + + def test_server_executed_flag_roundtrips(self): + part = llm.parts.ToolCallPart( + name="web_search", + arguments={"q": "x"}, + tool_call_id="c1", + server_executed=True, + ) + d = part.to_dict() + assert d["server_executed"] is True + restored = llm.parts.Part.from_dict(d) + assert restored.server_executed is True + + +class TestToolResultPart: + def test_roundtrip(self): + part = llm.parts.ToolResultPart( + name="search", output="72F sunny", tool_call_id="c1" + ) + restored = llm.parts.Part.from_dict(part.to_dict()) + assert restored == part + assert restored.exception is None + assert restored.attachments == [] + + def test_with_exception(self): + part = llm.parts.ToolResultPart( + name="t", output="", tool_call_id="c1", exception="boom" + ) + restored = llm.parts.Part.from_dict(part.to_dict()) + assert restored.exception == "boom" + + +class TestAttachmentPart: + def test_roundtrip_with_url(self): + att = llm.Attachment(url="http://example.com/cat.jpg") + part = llm.parts.AttachmentPart(attachment=att) + restored = llm.parts.Part.from_dict(part.to_dict()) + assert isinstance(restored, llm.parts.AttachmentPart) + assert restored.attachment.url == "http://example.com/cat.jpg" + + def test_roundtrip_with_path(self): + att = llm.Attachment(type="image/jpeg", path="/tmp/x.jpg") + part = llm.parts.AttachmentPart(attachment=att) + restored = llm.parts.Part.from_dict(part.to_dict()) + assert restored.attachment.path == "/tmp/x.jpg" + assert restored.attachment.type == "image/jpeg" + + def test_roundtrip_with_bytes_uses_base64(self): + raw = b"\x89PNG fake bytes" + att = llm.Attachment(type="image/png", content=raw) + part = llm.parts.AttachmentPart(attachment=att) + d = part.to_dict() + # Content must be a base64-encoded string in the dict form + assert isinstance(d["attachment"]["content"], str) + import base64 + + assert base64.b64decode(d["attachment"]["content"]) == raw + # And round-trip back to the original bytes + restored = llm.parts.Part.from_dict(d) + assert restored.attachment.content == raw + + def test_json_serializable(self): + att = llm.Attachment(type="image/png", content=b"\x00\x01\x02") + part = llm.parts.AttachmentPart(attachment=att) + # Must survive json dumps/loads + restored = llm.parts.Part.from_dict(json.loads(json.dumps(part.to_dict()))) + assert restored.attachment.content == b"\x00\x01\x02" + + +class TestUnknownPart: + def test_from_dict_unknown_type_raises(self): + with pytest.raises(ValueError): + llm.parts.Part.from_dict({"type": "nonsense"}) + + +class TestRoleNotOnPart: + def test_text_part_has_no_role_attribute(self): + # Role lives on Message. Parts are content-only. + part = llm.parts.TextPart(text="hi") + assert not hasattr(part, "role") + + def test_reasoning_part_has_no_role_attribute(self): + assert not hasattr(llm.parts.ReasoningPart(text=""), "role") + + def test_tool_call_part_has_no_role_attribute(self): + assert not hasattr( + llm.parts.ToolCallPart(name="t", arguments={}, tool_call_id="c1"), + "role", + ) + + +class TestMessage: + def test_roundtrip_simple_user_message(self): + m = llm.Message(role="user", parts=[llm.parts.TextPart(text="hi")]) + restored = llm.Message.from_dict(m.to_dict()) + assert restored == m + + def test_roundtrip_with_provider_metadata(self): + m = llm.Message( + role="assistant", + parts=[llm.parts.TextPart(text="hi")], + provider_metadata={"anthropic": {"signature": "abc"}}, + ) + restored = llm.Message.from_dict(m.to_dict()) + assert restored == m + + def test_roundtrip_mixed_parts(self): + m = llm.Message( + role="assistant", + parts=[ + llm.parts.ReasoningPart(text="Thinking"), + llm.parts.TextPart(text="Result"), + llm.parts.ToolCallPart( + name="search", + arguments={"q": "x"}, + tool_call_id="c1", + ), + ], + ) + restored = llm.Message.from_dict(m.to_dict()) + assert restored == m + + def test_empty_provider_metadata_omitted(self): + m = llm.Message(role="user", parts=[llm.parts.TextPart(text="x")]) + d = m.to_dict() + assert "provider_metadata" not in d + + def test_none_and_empty_provider_metadata_equivalent(self): + m_none = llm.Message(role="user", parts=[llm.parts.TextPart(text="x")]) + m_empty = llm.Message( + role="user", + parts=[llm.parts.TextPart(text="x")], + provider_metadata={}, + ) + # Both serialize the same (empty metadata is omitted) + assert m_none.to_dict() == m_empty.to_dict() + + +class TestHelpers: + def test_user_with_string(self): + m = llm.user("hi") + assert m.role == "user" + assert m.parts == [llm.parts.TextPart(text="hi")] + + def test_assistant_with_string(self): + m = llm.assistant("there") + assert m.role == "assistant" + assert m.parts == [llm.parts.TextPart(text="there")] + + def test_system_with_string(self): + m = llm.system("be brief") + assert m.role == "system" + assert m.parts == [llm.parts.TextPart(text="be brief")] + + def test_tool_message_with_part(self): + tr = llm.parts.ToolResultPart(name="t", output="r", tool_call_id="c1") + m = llm.tool_message(tr) + assert m.role == "tool" + assert m.parts == [tr] + + def test_helper_accepts_attachment(self): + att = llm.Attachment(url="http://example.com/x.jpg") + m = llm.user("describe this", att) + assert m.parts == [ + llm.parts.TextPart(text="describe this"), + llm.parts.AttachmentPart(attachment=att), + ] + + def test_helper_accepts_existing_part(self): + tp = llm.parts.TextPart(text="pre-built") + m = llm.user(tp) + assert m.parts == [tp] + + def test_helper_flattens_one_level(self): + # Nested list gets flattened one level. + m = llm.user(["one", "two"], "three") + assert m.parts == [ + llm.parts.TextPart(text="one"), + llm.parts.TextPart(text="two"), + llm.parts.TextPart(text="three"), + ] + + def test_helper_rejects_unknown_types(self): + with pytest.raises(TypeError): + llm.user(42) + + def test_helper_with_provider_metadata(self): + m = llm.assistant("hi", provider_metadata={"openai": {"id": "x"}}) + assert m.provider_metadata == {"openai": {"id": "x"}} + + +class TestStreamEvent: + def test_dataclass_defaults(self): + ev = llm.parts.StreamEvent(type="text", chunk="hi", part_index=0) + assert ev.type == "text" + assert ev.chunk == "hi" + assert ev.part_index == 0 + assert ev.tool_call_id is None + assert ev.server_executed is False + assert ev.tool_name is None + assert ev.provider_metadata is None + assert ev.message_index == 0 + + def test_all_fields_accepted(self): + ev = llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"q":', + part_index=2, + tool_call_id="c1", + server_executed=True, + tool_name="search", + provider_metadata={"openai": {"x": 1}}, + message_index=1, + ) + assert ev.tool_call_id == "c1" + assert ev.server_executed is True + assert ev.tool_name == "search" + assert ev.provider_metadata == {"openai": {"x": 1}} + assert ev.message_index == 1 + + +# Backward compat for plain-str plugins: iterating a Response still +# yields text strings, response.text() still works, self._chunks is +# still populated. + + +class TestPlainStrPluginCompat: + """A plugin that yields plain str must still work unchanged.""" + + def test_iter_yields_strings(self, mock_model): + mock_model.enqueue(["hello", " ", "world"]) + response = mock_model.prompt("hi") + chunks = list(response) + assert chunks == ["hello", " ", "world"] + + def test_text_returns_concatenation(self, mock_model): + mock_model.enqueue(["hello ", "world"]) + response = mock_model.prompt("hi") + assert response.text() == "hello world" + + def test_chunks_are_preserved(self, mock_model): + mock_model.enqueue(["a", "b", "c"]) + response = mock_model.prompt("hi") + response.text() + assert response._chunks == ["a", "b", "c"] + + +class TestStreamEventsFromPlainStrPlugin: + """When a plugin yields plain str, stream_events synthesizes text events.""" + + def test_stream_events_yields_text_events(self, mock_model): + mock_model.enqueue(["hel", "lo"]) + response = mock_model.prompt("hi") + events = list(response.stream_events()) + assert all(isinstance(e, llm.parts.StreamEvent) for e in events) + assert [e.type for e in events] == ["text", "text"] + assert [e.chunk for e in events] == ["hel", "lo"] + assert all(e.part_index == 0 for e in events) + + def test_response_messages_is_single_assistant_text(self, mock_model): + mock_model.enqueue(["hello"]) + response = mock_model.prompt("hi") + response.text() + messages = response.messages() + assert messages == [ + llm.Message(role="assistant", parts=[llm.parts.TextPart(text="hello")]) + ] + + def test_empty_response_has_empty_messages(self, mock_model): + mock_model.enqueue([]) + response = mock_model.prompt("hi") + response.text() + assert response.messages() == [] + + +class TestStreamEventsFromStreamEventPlugin: + """When a plugin yields StreamEvents, they pass through unchanged + and iteration filters to text only.""" + + def test_iter_yields_only_text_chunks(self, mock_model): + events = [ + llm.parts.StreamEvent(type="reasoning", chunk="think ", part_index=0), + llm.parts.StreamEvent(type="text", chunk="hel", part_index=1), + llm.parts.StreamEvent(type="text", chunk="lo", part_index=1), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + chunks = list(response) + assert chunks == ["hel", "lo"] + + def test_stream_events_yields_all_events(self, mock_model): + events = [ + llm.parts.StreamEvent(type="reasoning", chunk="t", part_index=0), + llm.parts.StreamEvent(type="text", chunk="x", part_index=1), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + got = list(response.stream_events()) + assert [e.type for e in got] == ["reasoning", "text"] + + def test_messages_assembles_reasoning_then_text(self, mock_model): + events = [ + llm.parts.StreamEvent(type="reasoning", chunk="thinking", part_index=0), + llm.parts.StreamEvent(type="text", chunk="hello", part_index=1), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + assert response.messages() == [ + llm.Message( + role="assistant", + parts=[ + llm.parts.ReasoningPart(text="thinking"), + llm.parts.TextPart(text="hello"), + ], + ) + ] + + def test_tool_call_name_and_args_merge(self, mock_model): + events = [ + llm.parts.StreamEvent(type="text", chunk="calling", part_index=0), + llm.parts.StreamEvent( + type="tool_call_name", + chunk="search", + part_index=1, + tool_call_id="c1", + ), + llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"q":', + part_index=1, + tool_call_id="c1", + ), + llm.parts.StreamEvent( + type="tool_call_args", + chunk='"weather"}', + part_index=1, + tool_call_id="c1", + ), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + msgs = response.messages() + assert len(msgs) == 1 + parts = msgs[0].parts + assert parts == [ + llm.parts.TextPart(text="calling"), + llm.parts.ToolCallPart( + name="search", + arguments={"q": "weather"}, + tool_call_id="c1", + ), + ] + + def test_tool_call_args_unparseable_json_falls_back(self, mock_model): + events = [ + llm.parts.StreamEvent( + type="tool_call_name", + chunk="t", + part_index=0, + tool_call_id="c1", + ), + llm.parts.StreamEvent( + type="tool_call_args", + chunk="not json", + part_index=0, + tool_call_id="c1", + ), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + part = response.messages()[0].parts[0] + assert part.name == "t" + assert part.arguments == {"_raw": "not json"} + + def test_family_mismatch_at_same_part_index_raises(self, mock_model): + events = [ + llm.parts.StreamEvent(type="text", chunk="x", part_index=0), + llm.parts.StreamEvent( + type="tool_call_name", + chunk="t", + part_index=0, + tool_call_id="c1", + ), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + with pytest.raises(ValueError, match="part_index"): + response.messages() # noqa: B018 + + def test_provider_metadata_merges_last_wins(self, mock_model): + events = [ + llm.parts.StreamEvent( + type="reasoning", + chunk="think", + part_index=0, + provider_metadata={"anthropic": {"signature": "one"}}, + ), + llm.parts.StreamEvent( + type="reasoning", + chunk="", + part_index=0, + provider_metadata={"anthropic": {"signature": "final"}}, + ), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + part = response.messages()[0].parts[0] + assert part.provider_metadata == {"anthropic": {"signature": "final"}} + + def test_redacted_reasoning_event_emits_marker_part(self, mock_model): + # A reasoning StreamEvent with redacted=True yields a + # ReasoningPart(text="", redacted=True) marker — opaque token + # totals live on response.token_details, not on the Part. + events = [ + llm.parts.StreamEvent(type="reasoning", chunk="", redacted=True), + llm.parts.StreamEvent(type="text", chunk="hi"), + ] + mock_model.enqueue(events) + response = mock_model.prompt("x") + response.text() + parts = response.messages()[0].parts + assert parts == [ + llm.parts.ReasoningPart(text="", redacted=True), + llm.parts.TextPart(text="hi"), + ] + + def test_redacted_reasoning_hoisted_to_start_when_emitted_late(self, mock_model): + # Plugins typically learn opaque reasoning happened only when + # the final usage chunk arrives, so they emit the marker last. + # The framework hoists redacted reasoning Parts to the start of + # the assembled message so UIs can render them before content. + events = [ + llm.parts.StreamEvent(type="text", chunk="hello"), + llm.parts.StreamEvent(type="reasoning", chunk="", redacted=True), + ] + mock_model.enqueue(events) + response = mock_model.prompt("x") + response.text() + parts = response.messages()[0].parts + assert parts == [ + llm.parts.ReasoningPart(text="", redacted=True), + llm.parts.TextPart(text="hello"), + ] + + def test_redacted_reasoning_event_default_redacted_is_false(self): + ev = llm.parts.StreamEvent(type="reasoning", chunk="thinking") + assert ev.redacted is False + + +class TestPartIndexAutoAllocation: + """When part_index is None (the default), the framework groups + events into Parts using same-family adjacency for text/reasoning + and tool_call_id for tool calls.""" + + def test_streamevent_part_index_defaults_to_none(self): + ev = llm.parts.StreamEvent(type="text", chunk="hi") + assert ev.part_index is None + + def test_consecutive_text_concatenates_into_one_part(self, mock_model): + events = [ + llm.parts.StreamEvent(type="text", chunk="hello "), + llm.parts.StreamEvent(type="text", chunk="world"), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + assert response.messages()[0].parts == [llm.parts.TextPart(text="hello world")] + + def test_text_then_reasoning_splits_into_two_parts(self, mock_model): + events = [ + llm.parts.StreamEvent(type="text", chunk="hello"), + llm.parts.StreamEvent(type="reasoning", chunk="thinking"), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + assert response.messages()[0].parts == [ + llm.parts.TextPart(text="hello"), + llm.parts.ReasoningPart(text="thinking"), + ] + + def test_text_tool_call_text_produces_three_parts(self, mock_model): + events = [ + llm.parts.StreamEvent(type="text", chunk="before"), + llm.parts.StreamEvent( + type="tool_call_name", + chunk="search", + tool_call_id="c1", + ), + llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"q": "x"}', + tool_call_id="c1", + ), + llm.parts.StreamEvent(type="text", chunk="after"), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + assert response.messages()[0].parts == [ + llm.parts.TextPart(text="before"), + llm.parts.ToolCallPart( + name="search", arguments={"q": "x"}, tool_call_id="c1" + ), + llm.parts.TextPart(text="after"), + ] + + def test_tool_call_groups_by_tool_call_id(self, mock_model): + events = [ + llm.parts.StreamEvent( + type="tool_call_name", + chunk="search", + tool_call_id="c1", + ), + llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"q":', + tool_call_id="c1", + ), + llm.parts.StreamEvent( + type="tool_call_args", + chunk='"weather"}', + tool_call_id="c1", + ), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + assert response.messages()[0].parts == [ + llm.parts.ToolCallPart( + name="search", + arguments={"q": "weather"}, + tool_call_id="c1", + ) + ] + + def test_parallel_tool_calls_interleaved_by_id(self, mock_model): + # Two tool calls whose args interleave on the wire — must + # still produce two distinct ToolCallParts grouped by id. + events = [ + llm.parts.StreamEvent( + type="tool_call_name", chunk="search", tool_call_id="A" + ), + llm.parts.StreamEvent( + type="tool_call_name", chunk="lookup", tool_call_id="B" + ), + llm.parts.StreamEvent( + type="tool_call_args", chunk='{"q":"a"}', tool_call_id="A" + ), + llm.parts.StreamEvent( + type="tool_call_args", chunk='{"k":"b"}', tool_call_id="B" + ), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + parts = response.messages()[0].parts + assert parts == [ + llm.parts.ToolCallPart( + name="search", arguments={"q": "a"}, tool_call_id="A" + ), + llm.parts.ToolCallPart( + name="lookup", arguments={"k": "b"}, tool_call_id="B" + ), + ] + + def test_tool_result_is_always_own_part(self, mock_model): + events = [ + llm.parts.StreamEvent( + type="tool_call_name", + chunk="web_search", + tool_call_id="c1", + server_executed=True, + ), + llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"q":"x"}', + tool_call_id="c1", + server_executed=True, + ), + llm.parts.StreamEvent( + type="tool_result", + chunk="results...", + tool_call_id="c1", + tool_name="web_search", + server_executed=True, + ), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + parts = response.messages()[0].parts + assert parts == [ + llm.parts.ToolCallPart( + name="web_search", + arguments={"q": "x"}, + tool_call_id="c1", + server_executed=True, + ), + llm.parts.ToolResultPart( + name="web_search", + output="results...", + tool_call_id="c1", + server_executed=True, + ), + ] + + def test_two_reasoning_blocks_split_by_tool_call(self, mock_model): + # Some providers emit two thinking blocks separated by a tool + # call — those should yield two ReasoningParts, not one. + events = [ + llm.parts.StreamEvent(type="reasoning", chunk="first"), + llm.parts.StreamEvent(type="tool_call_name", chunk="t", tool_call_id="c1"), + llm.parts.StreamEvent(type="tool_call_args", chunk="{}", tool_call_id="c1"), + llm.parts.StreamEvent(type="reasoning", chunk="second"), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + parts = response.messages()[0].parts + assert parts == [ + llm.parts.ReasoningPart(text="first"), + llm.parts.ToolCallPart(name="t", arguments={}, tool_call_id="c1"), + llm.parts.ReasoningPart(text="second"), + ] + + def test_parallel_tool_calls_without_id_each_get_own_part(self, mock_model): + # Gemini emits multiple functionCall parts back-to-back without + # a tool_call_id. Each tool_call_name must allocate a fresh + # part — otherwise the N tool calls collapse into one with + # concatenated names and args. + events = [ + llm.parts.StreamEvent(type="tool_call_name", chunk="store_fact"), + llm.parts.StreamEvent(type="tool_call_args", chunk='{"fact":"a"}'), + llm.parts.StreamEvent(type="tool_call_name", chunk="store_fact"), + llm.parts.StreamEvent(type="tool_call_args", chunk='{"fact":"b"}'), + llm.parts.StreamEvent(type="tool_call_name", chunk="store_fact"), + llm.parts.StreamEvent(type="tool_call_args", chunk='{"fact":"c"}'), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + parts = response.messages()[0].parts + assert parts == [ + llm.parts.ToolCallPart(name="store_fact", arguments={"fact": "a"}), + llm.parts.ToolCallPart(name="store_fact", arguments={"fact": "b"}), + llm.parts.ToolCallPart(name="store_fact", arguments={"fact": "c"}), + ] + + def test_explicit_part_index_still_works(self, mock_model): + # Back-compat: plugins that pass explicit part_index should + # behave exactly as before. + events = [ + llm.parts.StreamEvent(type="reasoning", chunk="t", part_index=0), + llm.parts.StreamEvent(type="text", chunk="hi", part_index=1), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + assert response.messages()[0].parts == [ + llm.parts.ReasoningPart(text="t"), + llm.parts.TextPart(text="hi"), + ] + + def test_mix_explicit_zero_and_none_for_text_concatenates(self, mock_model): + # Forcing a single TextPart across non-adjacent text bursts: + # plugin pins explicit part_index=0 on the wraparound text + # events, and the tool call in between gets None (auto). + events = [ + llm.parts.StreamEvent(type="text", chunk="before ", part_index=0), + llm.parts.StreamEvent(type="tool_call_name", chunk="t", tool_call_id="c1"), + llm.parts.StreamEvent(type="tool_call_args", chunk="{}", tool_call_id="c1"), + llm.parts.StreamEvent(type="text", chunk="after", part_index=0), + ] + mock_model.enqueue(events) + response = mock_model.prompt("hi") + response.text() + parts = response.messages()[0].parts + assert parts == [ + llm.parts.TextPart(text="before after"), + llm.parts.ToolCallPart(name="t", arguments={}, tool_call_id="c1"), + ] + + +class TestStreamEventsLiveDuringStreaming: + """Client code sees events arrive before the response is done""" + + def test_events_arrive_before_done(self, mock_model): + events = [ + llm.parts.StreamEvent(type="reasoning", chunk="t", part_index=0), + llm.parts.StreamEvent(type="text", chunk="hi", part_index=1), + ] + mock_model.enqueue(events) + response = mock_model.prompt("x") + seen = [] + for event in response.stream_events(): + # Record the _done state at the moment we receive the event. + seen.append((event.type, response._done)) + # Events arrived before _done was set. + assert [s[0] for s in seen] == ["reasoning", "text"] + assert all(not done for _type, done in seen) + # And after the generator is drained, the response is done. + assert response._done + + def test_stream_events_after_done_replays(self, mock_model): + mock_model.enqueue( + [llm.parts.StreamEvent(type="text", chunk="hi", part_index=0)] + ) + response = mock_model.prompt("x") + first = list(response.stream_events()) + # Second call replays from the stored events. + second = list(response.stream_events()) + assert len(first) == 1 + assert [e.type for e in second] == ["text"] + assert [e.chunk for e in second] == ["hi"] + + def test_plain_str_stream_events_after_done_replays(self, mock_model): + mock_model.enqueue(["hello"]) + response = mock_model.prompt("x") + response.text() + events = list(response.stream_events()) + assert len(events) == 1 + assert events[0].type == "text" + assert events[0].chunk == "hello" + + +class TestAsyncStreamEvents: + @pytest.mark.asyncio + async def test_async_stream_events_live(self, async_mock_model): + events = [ + llm.parts.StreamEvent(type="reasoning", chunk="r", part_index=0), + llm.parts.StreamEvent(type="text", chunk="t", part_index=1), + ] + async_mock_model.enqueue(events) + response = async_mock_model.prompt("x") + seen_types = [] + async for event in response.astream_events(): + seen_types.append(event.type) + assert seen_types == ["reasoning", "text"] + + @pytest.mark.asyncio + async def test_async_iter_yields_only_text(self, async_mock_model): + events = [ + llm.parts.StreamEvent(type="reasoning", chunk="r", part_index=0), + llm.parts.StreamEvent(type="text", chunk="hi", part_index=1), + ] + async_mock_model.enqueue(events) + response = async_mock_model.prompt("x") + chunks = [] + async for chunk in response: + chunks.append(chunk) + assert chunks == ["hi"] + + @pytest.mark.asyncio + async def test_async_messages_after_await(self, async_mock_model): + async_mock_model.enqueue(["hi"]) + response = async_mock_model.prompt("x") + await response.text() + assert await response.messages() == [ + llm.Message(role="assistant", parts=[llm.parts.TextPart(text="hi")]) + ] + + +class TestMessagesIsCallable: + """response.messages() is a method (matching .text(), .json(), + .tool_calls()) — invocation forces execution if not yet done. + """ + + def test_sync_messages_is_callable_and_returns_list(self, mock_model): + mock_model.enqueue(["hi"]) + response = mock_model.prompt("x") + # No prior .text() or iteration — calling messages() forces + # execution and returns the assembled list. + assert response.messages() == [ + llm.Message(role="assistant", parts=[llm.parts.TextPart(text="hi")]) + ] + + def test_sync_messages_after_text_returns_same_list(self, mock_model): + mock_model.enqueue(["hi"]) + response = mock_model.prompt("x") + response.text() + assert response.messages() == [ + llm.Message(role="assistant", parts=[llm.parts.TextPart(text="hi")]) + ] + + @pytest.mark.asyncio + async def test_async_messages_is_awaitable(self, async_mock_model): + async_mock_model.enqueue(["hi"]) + response = async_mock_model.prompt("x") + # No prior await — `await response.messages()` forces it. + result = await response.messages() + assert result == [ + llm.Message(role="assistant", parts=[llm.parts.TextPart(text="hi")]) + ] + + @pytest.mark.asyncio + async def test_async_messages_after_text_returns_same_list(self, async_mock_model): + async_mock_model.enqueue(["hi"]) + response = async_mock_model.prompt("x") + await response.text() + result = await response.messages() + assert result == [ + llm.Message(role="assistant", parts=[llm.parts.TextPart(text="hi")]) + ] + + +class TestPromptMessagesSynthesis: + """Prompt.messages constructs a Message list from legacy inputs when + messages= wasn't passed explicitly.""" + + def test_empty_prompt_yields_empty_messages(self, mock_model): + from llm.models import Prompt + + p = Prompt(None, model=mock_model) + assert p.messages == [] + + def test_prompt_text_synthesizes_user_message(self, mock_model): + from llm.models import Prompt + + p = Prompt("hi", model=mock_model) + assert p.messages == [ + llm.Message(role="user", parts=[llm.parts.TextPart(text="hi")]) + ] + + def test_system_and_prompt_synthesizes_two_messages(self, mock_model): + from llm.models import Prompt + + p = Prompt("hi", model=mock_model, system="be brief") + assert p.messages == [ + llm.Message(role="system", parts=[llm.parts.TextPart(text="be brief")]), + llm.Message(role="user", parts=[llm.parts.TextPart(text="hi")]), + ] + + def test_attachments_join_user_message(self, mock_model): + from llm.models import Prompt + + att = llm.Attachment(url="http://example.com/a.jpg") + p = Prompt("look", model=mock_model, attachments=[att]) + assert p.messages == [ + llm.Message( + role="user", + parts=[ + llm.parts.TextPart(text="look"), + llm.parts.AttachmentPart(attachment=att), + ], + ) + ] + + def test_tool_results_become_tool_role_message(self, mock_model): + from llm.models import Prompt + from llm import ToolResult + + tr = ToolResult(name="t", output="ok", tool_call_id="c1") + p = Prompt(None, model=mock_model, tool_results=[tr]) + assert p.messages == [ + llm.Message( + role="tool", + parts=[ + llm.parts.ToolResultPart(name="t", output="ok", tool_call_id="c1") + ], + ) + ] + + +class TestPromptMessagesExplicit: + """When messages= is passed, it's authoritative.""" + + def test_explicit_messages_returned_verbatim(self, mock_model): + from llm.models import Prompt + + explicit = [ + llm.system("x"), + llm.user("y"), + ] + p = Prompt(None, model=mock_model, messages=explicit) + assert p.messages == explicit + + def test_explicit_messages_ignores_prompt_kwarg(self, mock_model): + """Explicit messages= is authoritative. A prompt= string passed + alongside is no longer auto-appended — the invariant is that + prompt.messages equals exactly what the model was sent.""" + from llm.models import Prompt + + explicit = [llm.system("x"), llm.user("prior"), llm.user("follow-up")] + p = Prompt("ignored text", model=mock_model, messages=explicit) + assert p.messages == explicit + + def test_explicit_messages_independent_copy(self, mock_model): + """Mutating the caller's list must not mutate Prompt.messages.""" + from llm.models import Prompt + + explicit = [llm.user("x")] + p = Prompt(None, model=mock_model, messages=explicit) + explicit.append(llm.user("later")) + assert p.messages == [llm.user("x")] + + +class TestModelPromptMessagesKwarg: + """model.prompt / conversation.prompt / async counterparts accept + messages= and the list is observable on the resulting Prompt.""" + + def test_model_prompt_accepts_messages(self, mock_model): + mock_model.enqueue(["ok"]) + response = mock_model.prompt(messages=[llm.user("hi")]) + response.text() + assert response.prompt.messages == [llm.user("hi")] + + def test_model_prompt_messages_with_system(self, mock_model): + mock_model.enqueue(["ok"]) + response = mock_model.prompt(messages=[llm.system("be brief"), llm.user("hi")]) + response.text() + assert response.prompt.messages == [ + llm.system("be brief"), + llm.user("hi"), + ] + + def test_conversation_prompt_accepts_messages(self, mock_model): + mock_model.enqueue(["ok"]) + conv = mock_model.conversation() + response = conv.prompt(messages=[llm.user("q")]) + response.text() + assert response.prompt.messages == [llm.user("q")] + + @pytest.mark.asyncio + async def test_async_model_prompt_accepts_messages(self, async_mock_model): + async_mock_model.enqueue(["ok"]) + response = async_mock_model.prompt(messages=[llm.user("hi")]) + await response.text() + assert response.prompt.messages == [llm.user("hi")] + + @pytest.mark.asyncio + async def test_async_conversation_prompt_accepts_messages(self, async_mock_model): + async_mock_model.enqueue(["ok"]) + conv = async_mock_model.conversation() + response = conv.prompt(messages=[llm.user("q")]) + await response.text() + assert response.prompt.messages == [llm.user("q")] + + +# Invariant: response.prompt.messages == exactly what the model was +# sent for this turn, regardless of whether the caller used +# model.prompt(messages=[...]), conversation.prompt("text"), or +# response.reply("text"). + + +class TestConversationFullChainInvariant: + def test_explicit_messages_is_authoritative_no_prompt_combine(self, mock_model): + """Explicit messages= is the whole list. If prompt= is ALSO + passed, it's ignored for messages-building — the caller asked + for exact control.""" + mock_model.enqueue(["ok"]) + response = mock_model.prompt( + "this prompt argument is ignored", + messages=[llm.user("q")], + ) + response.text() + assert response.prompt.messages == [llm.user("q")] + + def test_conversation_second_turn_prompt_messages_has_full_chain(self, mock_model): + mock_model.enqueue(["a1"]) + mock_model.enqueue(["a2"]) + conv = mock_model.conversation() + + r1 = conv.prompt("q1") + r1.text() + r2 = conv.prompt("q2") + r2.text() + + # r2 was sent the full chain. + assert r2.prompt.messages == [ + llm.user("q1"), + llm.assistant("a1"), + llm.user("q2"), + ] + + def test_conversation_third_turn_includes_everything_before(self, mock_model): + mock_model.enqueue(["a1"]) + mock_model.enqueue(["a2"]) + mock_model.enqueue(["a3"]) + conv = mock_model.conversation() + r1 = conv.prompt("q1") + r1.text() + r2 = conv.prompt("q2") + r2.text() + r3 = conv.prompt("q3") + r3.text() + + assert r3.prompt.messages == [ + llm.user("q1"), + llm.assistant("a1"), + llm.user("q2"), + llm.assistant("a2"), + llm.user("q3"), + ] + + def test_conversation_first_turn_chain_is_single_user_message(self, mock_model): + mock_model.enqueue(["a1"]) + conv = mock_model.conversation() + r1 = conv.prompt("q1") + r1.text() + assert r1.prompt.messages == [llm.user("q1")] + + def test_conversation_preserves_reasoning_and_tool_call_parts(self, mock_model): + """The chain carries reasoning and tool calls from prior turns, + not just the flat text — required for multi-turn extended + thinking (Claude) and tool-use round-trips.""" + mock_model.enqueue( + [ + llm.parts.StreamEvent( + type="reasoning", chunk="thinking...", part_index=0 + ), + llm.parts.StreamEvent(type="text", chunk="answer", part_index=1), + ] + ) + mock_model.enqueue(["follow-up answer"]) + conv = mock_model.conversation() + r1 = conv.prompt("q1") + r1.text() + r2 = conv.prompt("q2") + r2.text() + + assert r2.prompt.messages == [ + llm.user("q1"), + llm.Message( + role="assistant", + parts=[ + llm.parts.ReasoningPart(text="thinking..."), + llm.parts.TextPart(text="answer"), + ], + ), + llm.user("q2"), + ] + + @pytest.mark.asyncio + async def test_async_conversation_full_chain(self, async_mock_model): + async_mock_model.enqueue(["a1"]) + async_mock_model.enqueue(["a2"]) + conv = async_mock_model.conversation() + r1 = conv.prompt("q1") + await r1.text() + r2 = conv.prompt("q2") + await r2.text() + + assert r2.prompt.messages == [ + llm.user("q1"), + llm.assistant("a1"), + llm.user("q2"), + ] + + +class TestSqliteRehydrateMessages: + """After Response.from_row, response.messages() must still yield the + assistant turn as a TextPart (+ any tool calls). Otherwise + Conversation.prompt builds a broken chain for `llm -c`. + """ + + def test_from_row_response_messages_synthesized_from_chunks( + self, mock_model, tmp_path + ): + import sqlite_utils + from llm.migrations import migrate + + mock_model.enqueue(["answer text"]) + r1 = mock_model.prompt("q1") + r1.text() + + db = sqlite_utils.Database(str(tmp_path / "logs.db")) + migrate(db) + r1.log_to_db(db) + + # Rehydrate the response + row = next(db["responses"].rows) + rehydrated = llm.Response.from_row(db, row) + # _stream_events is empty (SQLite doesn't persist those), but + # _chunks carries the text. response.messages() must fall back + # to synthesizing a TextPart. + assert rehydrated._stream_events == [] + assert rehydrated.messages() == [ + llm.Message( + role="assistant", parts=[llm.parts.TextPart(text="answer text")] + ) + ] + + def test_llm_dash_c_chain_preserves_prior_assistant_turn( + self, mock_model, tmp_path + ): + """End-to-end: a follow-up turn via load_conversation must send + [user(q1), assistant(a1), user(q2)] — not drop the assistant.""" + import sqlite_utils + from llm.migrations import migrate + from llm.cli import load_conversation + + mock_model.enqueue(["first answer"]) + mock_model.enqueue(["second answer"]) + r1 = mock_model.prompt("q1") + r1.text() + + db_path = tmp_path / "logs.db" + db = sqlite_utils.Database(str(db_path)) + migrate(db) + r1.log_to_db(db) + + conv = load_conversation(None, database=str(db_path)) + r2 = conv.prompt("q2") + r2.text() + + assert r2.prompt.messages == [ + llm.user("q1"), + llm.assistant("first answer"), + llm.user("q2"), + ] + + def test_llm_dash_c_after_logged_tool_chain_preserves_full_chain( + self, mock_model, tmp_path + ): + """A loaded tool-result response must carry the preceding + assistant tool_use. Otherwise Anthropic sees an orphan + tool_result at the start of the continued request.""" + import sqlite_utils + from llm.cli import load_conversation + from llm.migrations import migrate + + class ToolChainMock(type(mock_model)): + def __init__(self): + super().__init__() + self.calls = 0 + + def execute(self, prompt, stream, response, conversation): + self.calls += 1 + if self.calls == 1: + response.add_tool_call( + llm.ToolCall(name="tick", arguments={}, tool_call_id="c1") + ) + if False: + yield "" + else: + yield "final answer" + + def tick() -> str: + return "tock" + + m = ToolChainMock() + chain_response = m.chain("q1", tools=[tick]) + chain_response.text() + + db_path = tmp_path / "logs.db" + db = sqlite_utils.Database(str(db_path)) + migrate(db) + chain_response.log_to_db(db) + + conv = load_conversation(None, database=str(db_path)) + r3 = conv.prompt("q2") + + assert [m.role for m in r3.prompt.messages] == [ + "user", + "assistant", + "tool", + "assistant", + "user", + ] + assert isinstance(r3.prompt.messages[1].parts[0], llm.parts.ToolCallPart) + assert isinstance(r3.prompt.messages[2].parts[0], llm.parts.ToolResultPart) + assert r3.prompt.messages[2].parts[0].tool_call_id == "c1" + + +class TestAddToolCallWithStreamEvents: + """A plugin may yield StreamEvents *and* call response.add_tool_call(). + The Part list must include the tool call regardless of whether + _stream_events is empty or populated; otherwise persistence drops the + tool call and the next turn ships an orphan tool_result. + """ + + def test_text_yield_plus_add_tool_call_emits_both_parts(self, mock_model): + class TextAndAddToolCallMock(type(mock_model)): + def execute(self, prompt, stream, response, conversation): + yield "answer" + response.add_tool_call( + llm.ToolCall( + name="search", + arguments={"q": "weather"}, + tool_call_id="c1", + ) + ) + + m = TextAndAddToolCallMock() + response = m.prompt("hi") + response.text() + parts = response.messages()[0].parts + assert llm.parts.TextPart(text="answer") in parts + tool_call_parts = [p for p in parts if isinstance(p, llm.parts.ToolCallPart)] + assert tool_call_parts == [ + llm.parts.ToolCallPart( + name="search", + arguments={"q": "weather"}, + tool_call_id="c1", + ) + ] + + def test_stream_event_tool_call_plus_matching_add_tool_call_dedups( + self, mock_model + ): + class DualApiMock(type(mock_model)): + def execute(self, prompt, stream, response, conversation): + yield llm.parts.StreamEvent( + type="tool_call_name", chunk="search", tool_call_id="c1" + ) + yield llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"q":"weather"}', + tool_call_id="c1", + ) + response.add_tool_call( + llm.ToolCall( + name="search", + arguments={"q": "weather"}, + tool_call_id="c1", + ) + ) + + m = DualApiMock() + response = m.prompt("hi") + response.text() + tool_call_parts = [ + p + for p in response.messages()[0].parts + if isinstance(p, llm.parts.ToolCallPart) + ] + assert tool_call_parts == [ + llm.parts.ToolCallPart( + name="search", + arguments={"q": "weather"}, + tool_call_id="c1", + ) + ] + + +class TestResponseReply: + def test_reply_builds_next_turn_from_this_response(self, mock_model): + mock_model.enqueue(["a1"]) + mock_model.enqueue(["a2"]) + r1 = mock_model.prompt("q1") + r1.text() + + r2 = r1.reply("q2") + r2.text() + assert r2.prompt.messages == [ + llm.user("q1"), + llm.assistant("a1"), + llm.user("q2"), + ] + + def test_reply_chains(self, mock_model): + mock_model.enqueue(["a1"]) + mock_model.enqueue(["a2"]) + mock_model.enqueue(["a3"]) + r1 = mock_model.prompt("q1") + r1.text() + r2 = r1.reply("q2") + r2.text() + r3 = r2.reply("q3") + r3.text() + assert r3.prompt.messages == [ + llm.user("q1"), + llm.assistant("a1"), + llm.user("q2"), + llm.assistant("a2"), + llm.user("q3"), + ] + + def test_reply_no_prompt_reuses_messages_kwarg(self, mock_model): + """Passing messages= to reply() appends those onto the chain + in place of a new user string.""" + mock_model.enqueue(["a1"]) + mock_model.enqueue(["a2"]) + r1 = mock_model.prompt("q1") + r1.text() + r2 = r1.reply(messages=[llm.user("alt")]) + r2.text() + assert r2.prompt.messages == [ + llm.user("q1"), + llm.assistant("a1"), + llm.user("alt"), + ] + + def test_reply_from_conversation_response_extends_chain(self, mock_model): + mock_model.enqueue(["a1"]) + mock_model.enqueue(["a2"]) + conv = mock_model.conversation() + r1 = conv.prompt("q1") + r1.text() + r2 = r1.reply("q2") + r2.text() + assert r2.prompt.messages == [ + llm.user("q1"), + llm.assistant("a1"), + llm.user("q2"), + ] + + @pytest.mark.asyncio + async def test_async_reply(self, async_mock_model): + async_mock_model.enqueue(["a1"]) + async_mock_model.enqueue(["a2"]) + r1 = async_mock_model.prompt("q1") + await r1.text() + r2 = await r1.reply("q2") + await r2.text() + assert r2.prompt.messages == [ + llm.user("q1"), + llm.assistant("a1"), + llm.user("q2"), + ] + + def test_reply_with_tool_results_appends_tool_message(self, mock_model): + # model.prompt(...) makes tool calls, the + # caller runs them, then reply(tool_results=...) sends the + # results back in one call. The chain should grow by a + # role="tool" message containing ToolResultParts. + from llm.parts import ( + Message, + ToolCallPart, + ToolResultPart, + ) + + # First-turn assistant message has a tool call. + first_assistant = Message( + role="assistant", + parts=[ToolCallPart(name="echo", arguments={"x": 1}, tool_call_id="c1")], + ) + + class ToolCallMock(type(mock_model)): + supports_tools = True + + def execute(self, prompt, stream, response, conversation): + # Yield the assistant turn's parts as StreamEvents so + # response.messages() contains the tool call. + yield llm.parts.StreamEvent( + type="tool_call_name", + chunk="echo", + tool_call_id="c1", + ) + yield llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"x": 1}', + tool_call_id="c1", + ) + + m = ToolCallMock() + r1 = m.prompt("call echo") + r1.text() + + tool_results = [llm.ToolResult(name="echo", output="ok", tool_call_id="c1")] + # The bug we're fixing: this previously silently dropped the + # tool_results because reply() forwards via messages= and the + # Prompt synthesis path is bypassed. + m.enqueue(["follow-up text"]) + r2 = r1.reply(tool_results=tool_results) + r2.text() + assert r2.prompt.messages == [ + llm.user("call echo"), + first_assistant, + Message( + role="tool", + parts=[ToolResultPart(name="echo", output="ok", tool_call_id="c1")], + ), + ] + + def test_reply_with_tool_results_and_prompt(self, mock_model): + from llm.parts import ToolResultPart + + class ToolCallMock(type(mock_model)): + supports_tools = True + + def execute(self, prompt, stream, response, conversation): + yield llm.parts.StreamEvent( + type="tool_call_name", + chunk="echo", + tool_call_id="c1", + ) + yield llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"x": 1}', + tool_call_id="c1", + ) + + m = ToolCallMock() + r1 = m.prompt("call echo") + r1.text() + m.enqueue(["follow-up"]) + r2 = r1.reply( + "now summarise", + tool_results=[llm.ToolResult(name="echo", output="ok", tool_call_id="c1")], + ) + r2.text() + roles = [m.role for m in r2.prompt.messages] + assert roles == ["user", "assistant", "tool", "user"] + # tool message goes BEFORE the new user prompt. + tool_msg = r2.prompt.messages[2] + assert tool_msg.parts == [ + ToolResultPart(name="echo", output="ok", tool_call_id="c1") + ] + assert r2.prompt.messages[3] == llm.user("now summarise") + + def test_reply_auto_executes_tool_calls_when_none_passed(self, mock_model): + # Zero-arg sugar: response.reply() with tool calls present + # auto-executes them and threads results back into the chain. + from llm.parts import ToolResultPart + + executed = [] + + def echo(x: int) -> str: + executed.append(x) + return f"echo:{x}" + + class ToolCallMock(type(mock_model)): + supports_tools = True + + def execute(self, prompt, stream, response, conversation): + response.add_tool_call( + llm.ToolCall(name="echo", arguments={"x": 42}, tool_call_id="c1") + ) + yield llm.parts.StreamEvent( + type="tool_call_name", chunk="echo", tool_call_id="c1" + ) + yield llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"x": 42}', + tool_call_id="c1", + ) + + m = ToolCallMock() + r1 = m.prompt("call echo", tools=[echo]) + r1.text() + + m.enqueue(["follow-up"]) + # No tool_results passed — sugar kicks in and auto-executes. + r2 = r1.reply() + r2.text() + + assert executed == [42] + # The tool message landed in the chain. + roles = [msg.role for msg in r2.prompt.messages] + assert roles == ["user", "assistant", "tool"] + tool_msg = r2.prompt.messages[2] + assert tool_msg.parts == [ + ToolResultPart(name="echo", output="echo:42", tool_call_id="c1") + ] + + def test_reply_auto_execute_with_prompt(self, mock_model): + # reply("more text") with tool calls present also auto-executes + # so the user prompt can land after the tool results. + executed = [] + + def echo(x: int) -> str: + executed.append(x) + return "out" + + class ToolCallMock(type(mock_model)): + supports_tools = True + + def execute(self, prompt, stream, response, conversation): + response.add_tool_call( + llm.ToolCall(name="echo", arguments={"x": 1}, tool_call_id="c1") + ) + yield llm.parts.StreamEvent( + type="tool_call_name", chunk="echo", tool_call_id="c1" + ) + yield llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"x": 1}', + tool_call_id="c1", + ) + + m = ToolCallMock() + r1 = m.prompt("call echo", tools=[echo]) + r1.text() + m.enqueue(["follow-up"]) + r2 = r1.reply("now summarise") + r2.text() + assert executed == [1] + roles = [msg.role for msg in r2.prompt.messages] + assert roles == ["user", "assistant", "tool", "user"] + + def test_reply_explicit_tool_results_skips_auto_execute(self, mock_model): + # Passing tool_results= explicitly overrides the sugar — the + # tool function does NOT run (caller already ran it / wants + # custom results). + executed = [] + + def echo(x: int) -> str: + executed.append(x) + return "should not see" + + class ToolCallMock(type(mock_model)): + supports_tools = True + + def execute(self, prompt, stream, response, conversation): + yield llm.parts.StreamEvent( + type="tool_call_name", chunk="echo", tool_call_id="c1" + ) + yield llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"x": 1}', + tool_call_id="c1", + ) + + m = ToolCallMock() + r1 = m.prompt("call echo", tools=[echo]) + r1.text() + m.enqueue(["follow-up"]) + r2 = r1.reply( + tool_results=[ + llm.ToolResult(name="echo", output="custom", tool_call_id="c1") + ] + ) + r2.text() + assert executed == [] # echo was NOT called + tool_msg = r2.prompt.messages[2] + assert tool_msg.parts[0].output == "custom" + + def test_reply_no_tool_calls_no_tool_message(self, mock_model): + # reply() on a response without tool calls is unchanged — no + # tool message gets injected. + mock_model.enqueue(["a1"]) + mock_model.enqueue(["a2"]) + r1 = mock_model.prompt("q1") + r1.text() + r2 = r1.reply() + r2.text() + assert r2.prompt.messages == [ + llm.user("q1"), + llm.assistant("a1"), + ] + + @pytest.mark.asyncio + async def test_async_reply_auto_executes_tool_calls(self, async_mock_model): + # Async reply() is a coroutine; with tool calls present the + # zero-arg sugar awaits execute_tool_calls() internally. + from llm.parts import ToolResultPart + + executed = [] + + async def echo(x: int) -> str: + executed.append(x) + return f"echo:{x}" + + class ToolCallMock(type(async_mock_model)): + supports_tools = True + + async def execute(self, prompt, stream, response, conversation): + response.add_tool_call( + llm.ToolCall(name="echo", arguments={"x": 7}, tool_call_id="c1") + ) + yield llm.parts.StreamEvent( + type="tool_call_name", chunk="echo", tool_call_id="c1" + ) + yield llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"x": 7}', + tool_call_id="c1", + ) + + m = ToolCallMock() + r1 = m.prompt("call echo", tools=[echo]) + await r1.text() + m.enqueue(["follow-up"]) + r2 = await r1.reply() + await r2.text() + assert executed == [7] + tool_msg = r2.prompt.messages[2] + assert tool_msg.parts == [ + ToolResultPart(name="echo", output="echo:7", tool_call_id="c1") + ] + + @pytest.mark.asyncio + async def test_async_reply_with_tool_results(self, async_mock_model): + from llm.parts import ( + Message, + ToolCallPart, + ToolResultPart, + ) + + class ToolCallMock(type(async_mock_model)): + supports_tools = True + + async def execute(self, prompt, stream, response, conversation): + yield llm.parts.StreamEvent( + type="tool_call_name", + chunk="echo", + tool_call_id="c1", + ) + yield llm.parts.StreamEvent( + type="tool_call_args", + chunk='{"x": 1}', + tool_call_id="c1", + ) + + m = ToolCallMock() + r1 = m.prompt("call echo") + await r1.text() + m.enqueue(["follow-up"]) + r2 = await r1.reply( + tool_results=[llm.ToolResult(name="echo", output="ok", tool_call_id="c1")] + ) + await r2.text() + assert r2.prompt.messages == [ + llm.user("call echo"), + Message( + role="assistant", + parts=[ + ToolCallPart(name="echo", arguments={"x": 1}, tool_call_id="c1") + ], + ), + Message( + role="tool", + parts=[ToolResultPart(name="echo", output="ok", tool_call_id="c1")], + ), + ] + + +# chain() propagates system across tool-result turns + + +class TestChainPropagatesSystem: + """On a tool-result turn within a chain loop, the Prompt must + carry forward the original system= and system_fragments= so + adapters that read prompt.system (OpenAI and other + stateless-per-turn providers) see it on every call.""" + + def assert_system(self, prompt, *expected): + assert prompt.messages[0].role == "system" + for e in expected: + assert e in prompt.system + assert e in prompt.messages[0].parts[0].text + + def test_sync_chain_tool_result_turn_preserves_system(self, mock_model): + # First turn: fake a tool call so the chain iterates. + tool_call = llm.ToolCall(tool_call_id="c1", name="tick", arguments={}) + + class ChainMock(type(mock_model)): + def execute(self, prompt, stream, response, conversation): + if not self._queue: + yield "done" + return + msgs = self._queue.pop(0) + for m in msgs: + yield m + if not response._tool_calls: + response.add_tool_call(tool_call) + + def tick() -> str: + "Tick" + return "tock" + + m = ChainMock() + m.enqueue(["tool-turn"]) # first response; chain will loop + m.enqueue(["final"]) # second response, after tool results + + chain = m.chain("q", system="be brief", tools=[tick]) + list(chain.responses()) + # Second response was the tool-result turn. + self.assert_system(chain._responses[1].prompt, "be brief") + + def test_sync_chain_tool_result_turn_preserves_system_fragments(self, mock_model): + tool_call = llm.ToolCall(tool_call_id="c1", name="tick", arguments={}) + + class ChainMock(type(mock_model)): + def execute(self, prompt, stream, response, conversation): + if not self._queue: + yield "done" + return + msgs = self._queue.pop(0) + for m in msgs: + yield m + if not response._tool_calls: + response.add_tool_call(tool_call) + + def tick() -> str: + "Tick" + return "tock" + + m = ChainMock() + m.enqueue(["tool-turn"]) + m.enqueue(["final"]) + + chain = m.chain( + "q", + system="inline sys", + system_fragments=["fragment A", "fragment B"], + tools=[tick], + ) + list(chain.responses()) + self.assert_system( + chain._responses[1].prompt, "inline sys", "fragment A", "fragment B" + ) + + @pytest.mark.asyncio + async def test_async_chain_tool_result_turn_preserves_system( + self, async_mock_model + ): + tool_call = llm.ToolCall(tool_call_id="c1", name="tick", arguments={}) + + class AsyncChainMock(type(async_mock_model)): + supports_tools = True + + async def execute(self, prompt, stream, response, conversation): + if not self._queue: + yield "done" + return + msgs = self._queue.pop(0) + for m in msgs: + yield m + if not response._tool_calls: + response.add_tool_call(tool_call) + + def tick() -> str: + "Tick" + return "tock" + + m = AsyncChainMock() + m.enqueue(["tool-turn"]) + m.enqueue(["final"]) + + chain = m.chain("q", system="be brief", tools=[tick]) + responses = [] + async for r in chain.responses(): + responses.append(r) + self.assert_system(responses[1].prompt, "be brief") + + def test_chain_includes_system_in_messages(self, mock_model): + chain = mock_model.chain("q", system="be brief") + self.assert_system(chain.prompt, "be brief") + + +# chain() accepts messages= (parity with prompt()) + + +class TestChainMessagesKwarg: + def test_conversation_chain_accepts_messages(self, mock_model): + mock_model.enqueue(["ok"]) + conv = mock_model.conversation() + chain = conv.chain(messages=[llm.user("explicit")]) + chain.text() + r1 = chain._responses[0] + assert r1.prompt.messages == [llm.user("explicit")] + + def test_model_chain_accepts_messages(self, mock_model): + mock_model.enqueue(["ok"]) + chain = mock_model.chain(messages=[llm.user("explicit")]) + chain.text() + r1 = chain._responses[0] + assert r1.prompt.messages == [llm.user("explicit")] + + def test_chain_messages_is_authoritative_over_prompt_kwarg(self, mock_model): + """Parity with prompt(): when both are passed, messages= wins + and the prompt= string is not folded into the chain.""" + mock_model.enqueue(["ok"]) + chain = mock_model.chain( + "ignored text", + messages=[llm.user("explicit")], + ) + chain.text() + r1 = chain._responses[0] + assert r1.prompt.messages == [llm.user("explicit")] + + def test_chain_with_messages_and_prior_conversation(self, mock_model): + """Explicit messages= on chain() replaces history reconstruction; + the chain starts from that exact list.""" + mock_model.enqueue(["first"]) + mock_model.enqueue(["second"]) + conv = mock_model.conversation() + r1 = conv.prompt("prior") + r1.text() + + # Now start a chain with explicit messages= — prior turn is + # ignored (consistent with prompt() behavior). + chain = conv.chain(messages=[llm.user("fresh start")]) + chain.text() + first_chain_response = chain._responses[0] + assert first_chain_response.prompt.messages == [llm.user("fresh start")] + + @pytest.mark.asyncio + async def test_async_conversation_chain_accepts_messages(self, async_mock_model): + async_mock_model.enqueue(["ok"]) + conv = async_mock_model.conversation() + chain = conv.chain(messages=[llm.user("explicit")]) + await chain.text() + r1 = chain._responses[0] + assert r1.prompt.messages == [llm.user("explicit")] + + @pytest.mark.asyncio + async def test_async_model_chain_accepts_messages(self, async_mock_model): + async_mock_model.enqueue(["ok"]) + chain = async_mock_model.chain(messages=[llm.user("explicit")]) + await chain.text() + r1 = chain._responses[0] + assert r1.prompt.messages == [llm.user("explicit")] + + +# Response.to_dict / Response.from_dict + + +class TestResponseToDictFromDict: + def test_to_dict_captures_chain_and_output(self, mock_model): + mock_model.enqueue(["hello"]) + r = mock_model.prompt("hi") + r.text() + + d = r.to_dict() + assert d["model"] == "mock" + assert d["prompt"]["messages"] == [llm.user("hi").to_dict()] + assert d["messages"] == [llm.assistant("hello").to_dict()] + + def test_from_dict_rehydrates_with_messages(self, mock_model): + mock_model.enqueue(["hello"]) + r = mock_model.prompt("hi") + r.text() + payload = json.dumps(r.to_dict()) + + restored = llm.Response.from_dict(json.loads(payload)) + assert restored._done + assert restored.text() == "hello" + assert restored.messages() == [llm.assistant("hello")] + assert restored.prompt.messages == [llm.user("hi")] + + def test_from_dict_then_reply_continues_conversation(self, mock_model): + mock_model.enqueue(["a1"]) + mock_model.enqueue(["a2"]) + r1 = mock_model.prompt("q1") + r1.text() + + # Serialize across the process boundary + payload = json.dumps(r1.to_dict()) + restored = llm.Response.from_dict(json.loads(payload)) + + # Continue from the restored response + r2 = restored.reply("q2") + r2.text() + assert r2.prompt.messages == [ + llm.user("q1"), + llm.assistant("a1"), + llm.user("q2"), + ] + + def test_to_dict_preserves_reasoning_and_signatures(self, mock_model): + mock_model.enqueue( + [ + llm.parts.StreamEvent( + type="reasoning", + chunk="thinking...", + part_index=0, + provider_metadata={"anthropic": {"signature": "sig-abc"}}, + ), + llm.parts.StreamEvent(type="text", chunk="answer", part_index=1), + ] + ) + r = mock_model.prompt("q") + r.text() + + payload = json.dumps(r.to_dict()) + restored = llm.Response.from_dict(json.loads(payload)) + + msgs = restored.messages() + assert msgs[0].role == "assistant" + assert isinstance(msgs[0].parts[0], llm.parts.ReasoningPart) + assert msgs[0].parts[0].text == "thinking..." + assert msgs[0].parts[0].provider_metadata == { + "anthropic": {"signature": "sig-abc"} + } + + def test_from_dict_reply_includes_prior_reasoning_in_chain(self, mock_model): + """a reply() after from_dict() sends the thinking signature + back to the model for multi-turn extended thinking.""" + mock_model.enqueue( + [ + llm.parts.StreamEvent( + type="reasoning", + chunk="thinking...", + part_index=0, + provider_metadata={"anthropic": {"signature": "sig-xyz"}}, + ), + llm.parts.StreamEvent(type="text", chunk="answer", part_index=1), + ] + ) + mock_model.enqueue(["a2"]) + r1 = mock_model.prompt("q1") + r1.text() + + payload = json.dumps(r1.to_dict()) + restored = llm.Response.from_dict(json.loads(payload)) + r2 = restored.reply("q2") + r2.text() + + # The signature must be in the chain sent to the model. + chain = r2.prompt.messages + reasoning_parts = [ + p for m in chain for p in m.parts if isinstance(p, llm.parts.ReasoningPart) + ] + assert len(reasoning_parts) == 1 + assert reasoning_parts[0].provider_metadata == { + "anthropic": {"signature": "sig-xyz"} + } + + def test_to_dict_captures_options(self, mock_model): + mock_model.enqueue(["ok"]) + r = mock_model.prompt("hi", max_tokens=42) + r.text() + + d = r.to_dict() + assert d["prompt"]["options"] == {"max_tokens": 42} + + def test_from_dict_options_restored(self, mock_model): + mock_model.enqueue(["ok"]) + r = mock_model.prompt("hi", max_tokens=42) + r.text() + + payload = json.dumps(r.to_dict()) + restored = llm.Response.from_dict(json.loads(payload)) + assert restored.prompt.options.max_tokens == 42 + + def test_message_from_dict_static_method_unchanged(self): + m = llm.assistant("hi") + assert llm.Message.from_dict(m.to_dict()) == m + + +class TestChainResponseStreamEvents: + def test_sync_chain_stream_events_yields_text_when_no_tools(self, mock_model): + # Chain with no tool calls is a single-response chain — its + # stream_events should concatenate from each underlying response. + mock_model.enqueue( + [llm.parts.StreamEvent(type="text", chunk="done", part_index=0)] + ) + chain = mock_model.conversation().chain("q") + events = list(chain.stream_events()) + assert [e.type for e in events] == ["text"] + assert [e.chunk for e in events] == ["done"] + + @pytest.mark.asyncio + async def test_async_chain_astream_events_yields(self, async_mock_model): + async_mock_model.enqueue( + [llm.parts.StreamEvent(type="text", chunk="done", part_index=0)] + ) + chain = async_mock_model.conversation().chain("q") + events = [] + async for event in chain.astream_events(): + events.append(event) + assert [e.type for e in events] == ["text"] + + +# Client-side serialization round-trip +# +# A library user can persist a conversation by serializing response.messages +# to JSON and later re-inflate it as messages=[...] on a follow-up prompt. +# No SQLite involvement. + + +class TestClientSerializationRoundTrip: + def test_response_messages_json_roundtrip(self, mock_model): + mock_model.enqueue(["hello there"]) + r = mock_model.prompt("hi") + r.text() + + # Serialize via Message.to_dict / json.dumps + payload = json.dumps([m.to_dict() for m in r.messages()]) + # Deserialize — no LLM state needed beyond the types. + restored = [llm.Message.from_dict(d) for d in json.loads(payload)] + + assert restored == r.messages() + + def test_rebuilt_messages_reach_plugin_via_prompt(self, mock_model): + """Round-trip: serialize messages from turn 1, re-inflate, send + as messages= to turn 2. The plugin sees the full chain.""" + # Turn 1 + mock_model.enqueue(["turn 1 answer"]) + r1 = mock_model.prompt("turn 1 question") + r1.text() + + # Persist everything the client cares about. + history = [llm.user("turn 1 question").to_dict()] + [ + m.to_dict() for m in r1.messages() + ] + payload = json.dumps(history) + + # Later — rebuild from the wire form and continue. + rebuilt = [llm.Message.from_dict(d) for d in json.loads(payload)] + mock_model.enqueue(["turn 2 answer"]) + r2 = mock_model.prompt(messages=rebuilt + [llm.user("turn 2 question")]) + r2.text() + + # The plugin saw the full structured history on prompt.messages. + assert r2.prompt.messages == rebuilt + [llm.user("turn 2 question")] + assert r2.messages() == [llm.assistant("turn 2 answer")] + + def test_roundtrip_preserves_tool_calls_and_results(self, mock_model): + """Assistant messages with tool calls + subsequent tool role + messages survive json round-trip intact.""" + messages = [ + llm.user("what's the weather?"), + llm.assistant( + "let me check", + llm.parts.ToolCallPart( + name="get_weather", + arguments={"city": "Paris"}, + tool_call_id="c1", + ), + ), + llm.tool_message( + llm.parts.ToolResultPart( + name="get_weather", + output="sunny", + tool_call_id="c1", + ) + ), + ] + payload = json.dumps([m.to_dict() for m in messages]) + restored = [llm.Message.from_dict(d) for d in json.loads(payload)] + assert restored == messages + + def test_roundtrip_preserves_redacted_reasoning(self, mock_model): + """The redacted=True marker on a ReasoningPart survives + round-trip — UIs use it to show that opaque reasoning happened + in this turn (the actual token count lives on response usage).""" + msg = llm.Message( + role="assistant", + parts=[ + llm.parts.ReasoningPart(text="", redacted=True), + llm.parts.TextPart(text="result"), + ], + ) + restored = llm.Message.from_dict(json.loads(json.dumps(msg.to_dict()))) + assert restored == msg + + def test_roundtrip_preserves_provider_metadata(self, mock_model): + msg = llm.Message( + role="assistant", + parts=[ + llm.parts.ReasoningPart( + text="thinking", + provider_metadata={"anthropic": {"signature": "abc"}}, + ), + llm.parts.TextPart(text="answer"), + ], + ) + restored = llm.Message.from_dict(json.loads(json.dumps(msg.to_dict()))) + assert restored == msg diff --git a/tests/test_pause_resume.py b/tests/test_pause_resume.py new file mode 100644 index 0000000..25bb37a --- /dev/null +++ b/tests/test_pause_resume.py @@ -0,0 +1,391 @@ +"""Tests for llm.PauseChain and chain resume from message history.""" + +import asyncio +import json + +import pytest + +import llm +from llm.parts import Message, TextPart, ToolCallPart, ToolResultPart + +# ---- PauseChain ---- + + +def test_pause_chain_sync_model(): + after_calls = [] + + def needs_input(path: str) -> str: + raise llm.PauseChain("waiting for approval") + + def before(tool, tool_call): + pass + + def after(tool, tool_call, tool_result): + after_calls.append(tool_result.name) + + model = llm.get_model("echo") + chain = model.chain( + json.dumps( + {"tool_calls": [{"name": "needs_input", "arguments": {"path": "/tmp"}}]} + ), + tools=[needs_input], + before_call=before, + after_call=after, + ) + with pytest.raises(llm.PauseChain) as exc_info: + chain.text() + + pause = exc_info.value + assert str(pause) == "waiting for approval" + assert pause.tool_call is not None + assert pause.tool_call.name == "needs_input" + assert pause.tool_call.arguments == {"path": "/tmp"} + assert pause.tool_call.tool_call_id.startswith("tc_") + assert pause.tool_results == [] + # after_call must not fire for the paused tool + assert after_calls == [] + # The response that requested the tool call completed normally + assert len(chain._responses) == 1 + + +@pytest.mark.asyncio +async def test_pause_chain_async_model_siblings_complete(): + after_calls = [] + executed = [] + + async def needs_input() -> str: + raise llm.PauseChain("hold on") + + async def sibling() -> str: + await asyncio.sleep(0.01) + executed.append("sibling") + return "done" + + async def after(tool, tool_call, tool_result): + after_calls.append(tool_result.name) + + model = llm.get_async_model("echo") + chain = model.chain( + json.dumps({"tool_calls": [{"name": "needs_input"}, {"name": "sibling"}]}), + tools=[needs_input, sibling], + after_call=after, + ) + with pytest.raises(llm.PauseChain) as exc_info: + await chain.text() + + pause = exc_info.value + assert pause.tool_call.name == "needs_input" + # The concurrent sibling ran to completion - no orphaned tasks + assert executed == ["sibling"] + assert after_calls == ["sibling"] + # Completed sibling results ride on the exception + assert [r.name for r in pause.tool_results] == ["sibling"] + assert pause.tool_results[0].output == "done" + + +def test_pause_chain_sync_model_stops_remaining_calls(): + executed = [] + + def pauser() -> str: + raise llm.PauseChain("wait") + + def later() -> str: + executed.append("later") + return "x" + + model = llm.get_model("echo") + chain = model.chain( + json.dumps({"tool_calls": [{"name": "pauser"}, {"name": "later"}]}), + tools=[pauser, later], + ) + with pytest.raises(llm.PauseChain) as exc_info: + chain.text() + # Sequential execution stops at the pause; later call never starts, + # so it can safely re-execute on resume. + assert executed == [] + assert exc_info.value.tool_results == [] + + +@pytest.mark.asyncio +async def test_pause_chain_async_first_of_two_pauses_propagates(): + async def pause_a() -> str: + raise llm.PauseChain("a") + + async def pause_b() -> str: + raise llm.PauseChain("b") + + model = llm.get_async_model("echo") + chain = model.chain( + json.dumps({"tool_calls": [{"name": "pause_a"}, {"name": "pause_b"}]}), + tools=[pause_a, pause_b], + ) + with pytest.raises(llm.PauseChain) as exc_info: + await chain.text() + assert str(exc_info.value) == "a" + assert exc_info.value.tool_call.name == "pause_a" + + +@pytest.mark.asyncio +async def test_async_hook_exception_does_not_orphan_siblings(): + """Defined failure semantics: an exception raised by an after_call + hook propagates only after all concurrent tool tasks finish.""" + executed = [] + + async def boomer() -> str: + return "boom" + + async def slow() -> str: + await asyncio.sleep(0.05) + executed.append("slow") + return "ok" + + async def after(tool, tool_call, tool_result): + if tool_result.name == "boomer": + raise ValueError("hook bug") + + model = llm.get_async_model("echo") + chain = model.chain( + json.dumps({"tool_calls": [{"name": "boomer"}, {"name": "slow"}]}), + tools=[boomer, slow], + after_call=after, + ) + with pytest.raises(ValueError, match="hook bug"): + await chain.text() + # The slow sibling was not orphaned mid-flight + assert executed == ["slow"] + + +@pytest.mark.asyncio +async def test_pause_chain_async_model_sync_tool(): + def pauser() -> str: + raise llm.PauseChain("wait") + + model = llm.get_async_model("echo") + chain = model.chain( + json.dumps({"tool_calls": [{"name": "pauser"}]}), + tools=[pauser], + ) + with pytest.raises(llm.PauseChain) as exc_info: + await chain.text() + assert exc_info.value.tool_call.name == "pauser" + + +# ---- chain resume from message history ---- + + +def _pending_history(tool_call_id="tc_resume1"): + return [ + Message(role="user", parts=[TextPart(text="Convert hello to uppercase")]), + Message( + role="assistant", + parts=[ + ToolCallPart( + name="upper", + arguments={"text": "hello"}, + tool_call_id=tool_call_id, + ) + ], + ), + ] + + +def test_chain_resumes_trailing_pending_tool_calls(): + executed = [] + hook_calls = [] + + def upper(text: str) -> str: + executed.append(text) + return text.upper() + + def before(tool, tool_call): + hook_calls.append(("before", tool_call.name, tool_call.tool_call_id)) + + def after(tool, tool_call, tool_result): + hook_calls.append(("after", tool_result.name, tool_result.tool_call_id)) + + model = llm.get_model("echo") + chain = model.chain( + None, + messages=_pending_history(), + tools=[upper], + before_call=before, + after_call=after, + ) + output = chain.text() + + # The pending call executed through the normal hook machinery + assert executed == ["hello"] + assert hook_calls == [ + ("before", "upper", "tc_resume1"), + ("after", "upper", "tc_resume1"), + ] + # The model then received the tool result (echo renders + # prompt.tool_results), correlated by the original id + data = json.loads(output) + assert data["tool_results"] == [ + {"name": "upper", "output": "HELLO", "tool_call_id": "tc_resume1"} + ] + # Exactly one provider call was made + assert len(chain._responses) == 1 + + +@pytest.mark.asyncio +async def test_chain_resumes_trailing_pending_tool_calls_async(): + executed = [] + + async def upper(text: str) -> str: + executed.append(text) + return text.upper() + + model = llm.get_async_model("echo") + chain = model.chain(None, messages=_pending_history(), tools=[upper]) + output = await chain.text() + + assert executed == ["hello"] + data = json.loads(output) + assert data["tool_results"] == [ + {"name": "upper", "output": "HELLO", "tool_call_id": "tc_resume1"} + ] + + +def test_resume_skips_calls_that_already_have_results(): + executed = [] + + def first() -> str: + executed.append("first") + return "one" + + def second() -> str: + executed.append("second") + return "two" + + history = [ + Message(role="user", parts=[TextPart(text="go")]), + Message( + role="assistant", + parts=[ + ToolCallPart(name="first", arguments={}, tool_call_id="tc_a"), + ToolCallPart(name="second", arguments={}, tool_call_id="tc_b"), + ], + ), + Message( + role="tool", + parts=[ToolResultPart(name="first", output="one", tool_call_id="tc_a")], + ), + ] + model = llm.get_model("echo") + chain = model.chain(None, messages=history, tools=[first, second]) + output = chain.text() + + assert executed == ["second"] + data = json.loads(output) + assert data["tool_results"] == [ + {"name": "second", "output": "two", "tool_call_id": "tc_b"} + ] + + +def test_no_resume_when_conversation_moved_on(): + executed = [] + + def upper(text: str) -> str: + executed.append(text) + return text.upper() + + history = _pending_history() + [ + Message(role="user", parts=[TextPart(text="never mind")]), + ] + model = llm.get_model("echo") + chain = model.chain(None, messages=history, tools=[upper]) + chain.text() + assert executed == [] + + +def test_no_resume_without_tools(): + model = llm.get_model("echo") + chain = model.chain(None, messages=_pending_history()) + # No tools provided: nothing to execute, chain proceeds normally + output = chain.text() + assert "tool_results" not in json.loads(output) + + +def test_resume_matches_idless_calls_by_name(): + # Histories persisted before guaranteed ids may have None ids + executed = [] + + def upper(text: str) -> str: + executed.append(text) + return text.upper() + + history = [ + Message(role="user", parts=[TextPart(text="go")]), + Message( + role="assistant", + parts=[ + ToolCallPart(name="upper", arguments={"text": "a"}, tool_call_id=None), + ToolCallPart(name="upper", arguments={"text": "b"}, tool_call_id=None), + ], + ), + Message( + role="tool", + parts=[ToolResultPart(name="upper", output="A", tool_call_id=None)], + ), + ] + model = llm.get_model("echo") + chain = model.chain(None, messages=history, tools=[upper]) + chain.text() + # One result already present: only one of the two calls re-executes + assert executed == ["b"] + + +def test_resume_ignores_server_executed_calls(): + executed = [] + + def upper(text: str) -> str: + executed.append(text) + return text.upper() + + history = [ + Message(role="user", parts=[TextPart(text="go")]), + Message( + role="assistant", + parts=[ + ToolCallPart( + name="upper", + arguments={"text": "x"}, + tool_call_id="tc_srv", + server_executed=True, + ) + ], + ), + ] + model = llm.get_model("echo") + chain = model.chain(None, messages=history, tools=[upper]) + chain.text() + assert executed == [] + + +def test_resumed_tool_can_pause_again(): + def needs_more(text: str) -> str: + raise llm.PauseChain("second question") + + history = [ + Message(role="user", parts=[TextPart(text="go")]), + Message( + role="assistant", + parts=[ + ToolCallPart( + name="needs_more", + arguments={"text": "x"}, + tool_call_id="tc_again", + ) + ], + ), + ] + model = llm.get_model("echo") + chain = model.chain(None, messages=history, tools=[needs_more]) + with pytest.raises(llm.PauseChain) as exc_info: + chain.text() + assert exc_info.value.tool_call.name == "needs_more" + assert exc_info.value.tool_call.tool_call_id == "tc_again" + # No provider call was made: the chain paused before reaching the model + assert len(chain._responses) == 0 diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000..6197ef5 --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,1056 @@ +from click.testing import CliRunner +import click +import importlib +import json +import llm +from llm.tools import llm_version, llm_time +from llm import cli, hookimpl, plugins, get_template_loaders, get_fragment_loaders +import pathlib +import re +from unittest.mock import ANY +import pytest +import textwrap + + +def test_register_commands(): + importlib.reload(cli) + + def plugin_names(): + return [plugin["name"] for plugin in llm.get_plugins()] + + assert "HelloWorldPlugin" not in plugin_names() + + class HelloWorldPlugin: + __name__ = "HelloWorldPlugin" + + @hookimpl + def register_commands(self, cli): + @cli.command(name="hello-world") + def hello_world(): + "Print hello world" + click.echo("Hello world!") + + try: + plugins.pm.register(HelloWorldPlugin(), name="HelloWorldPlugin") + importlib.reload(cli) + + assert "HelloWorldPlugin" in plugin_names() + + runner = CliRunner() + result = runner.invoke(cli.cli, ["hello-world"]) + assert result.exit_code == 0 + assert result.output == "Hello world!\n" + + finally: + plugins.pm.unregister(name="HelloWorldPlugin") + importlib.reload(cli) + assert "HelloWorldPlugin" not in plugin_names() + + +def test_register_template_loaders(): + assert get_template_loaders() == {} + + def one_loader(template_path): + return llm.Template(name="one:" + template_path, prompt=template_path) + + def two_loader(template_path): + "Docs for two" + return llm.Template(name="two:" + template_path, prompt=template_path) + + def dupe_two_loader(template_path): + "Docs for two dupe" + return llm.Template(name="two:" + template_path, prompt=template_path) + + class TemplateLoadersPlugin: + __name__ = "TemplateLoadersPlugin" + + @hookimpl + def register_template_loaders(self, register): + register("one", one_loader) + register("two", two_loader) + register("two", dupe_two_loader) + + try: + plugins.pm.register(TemplateLoadersPlugin(), name="TemplateLoadersPlugin") + loaders = get_template_loaders() + assert loaders == { + "one": one_loader, + "two": two_loader, + "two_1": dupe_two_loader, + } + + # Test the CLI command + runner = CliRunner() + result = runner.invoke(cli.cli, ["templates", "loaders"]) + assert result.exit_code == 0 + assert result.output == ( + "one:\n" + " Undocumented\n" + "two:\n" + " Docs for two\n" + "two_1:\n" + " Docs for two dupe\n" + ) + + finally: + plugins.pm.unregister(name="TemplateLoadersPlugin") + assert get_template_loaders() == {} + + +def test_register_fragment_loaders(logs_db, httpx_mock): + httpx_mock.add_response( + method="HEAD", + url="https://example.com/attachment.png", + content=b"attachment", + headers={"Content-Type": "image/png"}, + is_reusable=True, + ) + + assert get_fragment_loaders() == {} + + def single_fragment(argument): + "This is the fragment documentation" + return llm.Fragment("single", "single") + + def three_fragments(argument): + return [ + llm.Fragment(f"one:{argument}", "one"), + llm.Fragment(f"two:{argument}", "two"), + llm.Fragment(f"three:{argument}", "three"), + ] + + def fragment_and_attachment(argument): + return [ + llm.Fragment(f"one:{argument}", "one"), + llm.Attachment(url="https://example.com/attachment.png"), + ] + + class FragmentLoadersPlugin: + __name__ = "FragmentLoadersPlugin" + + @hookimpl + def register_fragment_loaders(self, register): + register("single", single_fragment) + register("three", three_fragments) + register("mixed", fragment_and_attachment) + + try: + plugins.pm.register(FragmentLoadersPlugin(), name="FragmentLoadersPlugin") + loaders = get_fragment_loaders() + assert loaders == { + "single": single_fragment, + "three": three_fragments, + "mixed": fragment_and_attachment, + } + + # Test the CLI command + runner = CliRunner() + result = runner.invoke( + cli.cli, ["-m", "echo", "-f", "three:x"], catch_exceptions=False + ) + assert result.exit_code == 0 + assert json.loads(result.output) == { + "prompt": "one:x\ntwo:x\nthree:x", + "system": "", + "attachments": [], + "stream": True, + "previous": [], + } + # And the llm fragments loaders command: + result2 = runner.invoke(cli.cli, ["fragments", "loaders"]) + assert result2.exit_code == 0 + expected2 = ( + "single:\n" + " This is the fragment documentation\n" + "\n" + "three:\n" + " Undocumented\n" + "\n" + "mixed:\n" + " Undocumented\n" + ) + assert result2.output == expected2 + + # Test the one that includes an attachment + result3 = runner.invoke( + cli.cli, ["-m", "echo", "-f", "mixed:x"], catch_exceptions=False + ) + assert result3.exit_code == 0 + result3.output.strip == textwrap.dedent("""\ + system: + + + prompt: + one:x + + attachments: + - https://example.com/attachment.png + """).strip() + + finally: + plugins.pm.unregister(name="FragmentLoadersPlugin") + assert get_fragment_loaders() == {} + + # Let's check the database + assert list(logs_db.query("select content, source from fragments")) == [ + {"content": "one:x", "source": "one"}, + {"content": "two:x", "source": "two"}, + {"content": "three:x", "source": "three"}, + ] + + +def test_register_tools(tmpdir, logs_db): + def upper(text: str) -> str: + """Convert text to uppercase.""" + return text.upper() + + def count_character_in_word(text: str, character: str) -> int: + """Count the number of occurrences of a character in a word.""" + return text.count(character) + + def output_as_json(text: str): + return {"this_is_in_json": {"nested": text}} + + class ToolsPlugin: + __name__ = "ToolsPlugin" + + @hookimpl + def register_tools(self, register): + register(llm.Tool.function(upper)) + register(count_character_in_word, name="count_chars") + register(output_as_json) + + try: + plugins.pm.register(ToolsPlugin(), name="ToolsPlugin") + tools = llm.get_tools() + assert tools == { + "upper": llm.Tool( + name="upper", + description="Convert text to uppercase.", + input_schema={ + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "type": "object", + }, + implementation=upper, + plugin="ToolsPlugin", + ), + "count_chars": llm.Tool( + name="count_chars", + description="Count the number of occurrences of a character in a word.", + input_schema={ + "properties": { + "text": {"type": "string"}, + "character": {"type": "string"}, + }, + "required": ["text", "character"], + "type": "object", + }, + implementation=count_character_in_word, + plugin="ToolsPlugin", + ), + "llm_version": llm.Tool( + name="llm_version", + description="Return the installed version of llm", + input_schema={"properties": {}, "type": "object"}, + implementation=llm_version, + plugin="llm.default_plugins.default_tools", + ), + "output_as_json": llm.Tool( + name="output_as_json", + description=None, + input_schema={ + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "type": "object", + }, + implementation=output_as_json, + plugin="ToolsPlugin", + ), + "llm_time": llm.Tool( + name="llm_time", + description="Returns the current time, as local time and UTC", + input_schema={"properties": {}, "type": "object"}, + implementation=llm_time, + plugin="llm.default_plugins.default_tools", + ), + } + + # Test the CLI command + runner = CliRunner() + result = runner.invoke(cli.cli, ["tools", "list"]) + assert result.exit_code == 0 + assert result.output == ( + "count_chars(text: str, character: str) -> int (plugin: ToolsPlugin)\n\n" + " Count the number of occurrences of a character in a word.\n\n" + "llm_time() -> dict (plugin: llm.default_plugins.default_tools)\n\n" + " Returns the current time, as local time and UTC\n\n" + "llm_version() -> str (plugin: llm.default_plugins.default_tools)\n\n" + " Return the installed version of llm\n\n" + "output_as_json(text: str) (plugin: ToolsPlugin)\n\n" + "upper(text: str) -> str (plugin: ToolsPlugin)\n\n" + " Convert text to uppercase.\n\n" + ) + # And --json + result2 = runner.invoke(cli.cli, ["tools", "list", "--json"]) + assert result2.exit_code == 0 + assert json.loads(result2.output) == { + "tools": [ + { + "name": "count_chars", + "description": "Count the number of occurrences of a character in a word.", + "arguments": { + "properties": { + "text": {"type": "string"}, + "character": {"type": "string"}, + }, + "required": ["text", "character"], + "type": "object", + }, + "plugin": "ToolsPlugin", + }, + { + "arguments": { + "properties": {}, + "type": "object", + }, + "description": "Returns the current time, as local time and UTC", + "name": "llm_time", + "plugin": "llm.default_plugins.default_tools", + }, + { + "name": "llm_version", + "description": "Return the installed version of llm", + "arguments": {"properties": {}, "type": "object"}, + "plugin": "llm.default_plugins.default_tools", + }, + { + "name": "output_as_json", + "description": None, + "arguments": { + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "type": "object", + }, + "plugin": "ToolsPlugin", + }, + { + "name": "upper", + "description": "Convert text to uppercase.", + "arguments": { + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "type": "object", + }, + "plugin": "ToolsPlugin", + }, + ], + "toolboxes": [], + } + + # And test the --tools option + functions_path = str(tmpdir / "functions.py") + with open(functions_path, "w") as fp: + fp.write("def example(s: str, i: int):\n return s + '-' + str(i)") + result3 = runner.invoke( + cli.cli, + [ + "tools", + "--functions", + "def reverse(s: str): return s[::-1]", + "--functions", + functions_path, + ], + ) + assert result3.exit_code == 0 + assert "reverse(s: str)" in result3.output + assert "example(s: str, i: int)" in result3.output + # Now run a prompt using a plugin tool and to check it gets logged correctly + result4 = runner.invoke( + cli.cli, + [ + "-m", + "echo", + "--tool", + "upper", + json.dumps( + {"tool_calls": [{"name": "upper", "arguments": {"text": "hi"}}]} + ), + ], + catch_exceptions=False, + ) + assert result4.exit_code == 0 + assert '"output": "HI"' in result4.output + + # Now check in the database + tool_row = [row for row in logs_db["tools"].rows][0] + assert tool_row["name"] == "upper" + assert tool_row["plugin"] == "ToolsPlugin" + + # The llm logs command should return that, including with the -T upper option + for args in ([], ["-T", "upper"]): + logs_result = runner.invoke(cli.cli, ["logs"] + args) + assert logs_result.exit_code == 0 + assert "HI" in logs_result.output + # ... but not for -T reverse + logs_empty_result = runner.invoke(cli.cli, ["logs", "-T", "count_chars"]) + assert logs_empty_result.exit_code == 0 + assert "HI" not in logs_empty_result.output + + # Start with a tool, use llm -c to reuse the same tool + result5 = runner.invoke( + cli.cli, + [ + "prompt", + "-m", + "echo", + "--tool", + "upper", + json.dumps( + {"tool_calls": [{"name": "upper", "arguments": {"text": "one"}}]} + ), + ], + ) + assert result5.exit_code == 0 + assert ( + runner.invoke( + cli.cli, + [ + "-c", + json.dumps( + { + "tool_calls": [ + {"name": "upper", "arguments": {"text": "two"}} + ] + } + ), + ], + ).exit_code + == 0 + ) + # Now do it again with llm chat -c + assert ( + runner.invoke( + cli.cli, + ["chat", "-c"], + input=( + json.dumps( + { + "tool_calls": [ + {"name": "upper", "arguments": {"text": "three"}} + ] + } + ) + + "\nquit\n" + ), + catch_exceptions=False, + ).exit_code + == 0 + ) + # Should have logged those three tool uses in llm logs -c -n 0 + log_rows = json.loads( + runner.invoke(cli.cli, ["logs", "-c", "-n", "0", "--json"]).output + ) + results = tuple( + ( + log_row["prompt"], + re.sub( + r"tc_[0-9a-z]{26}", "tc_TCID", json.dumps(log_row["tool_results"]) + ), + ) + for log_row in log_rows + ) + assert results == ( + ('{"tool_calls": [{"name": "upper", "arguments": {"text": "one"}}]}', "[]"), + ( + "", + '[{"id": 2, "tool_id": 1, "name": "upper", "output": "ONE", "tool_call_id": "tc_TCID", "exception": null, "attachments": []}]', + ), + ('{"tool_calls": [{"name": "upper", "arguments": {"text": "two"}}]}', "[]"), + ( + "", + '[{"id": 3, "tool_id": 1, "name": "upper", "output": "TWO", "tool_call_id": "tc_TCID", "exception": null, "attachments": []}]', + ), + ( + '{"tool_calls": [{"name": "upper", "arguments": {"text": "three"}}]}', + "[]", + ), + ( + "", + '[{"id": 4, "tool_id": 1, "name": "upper", "output": "THREE", "tool_call_id": "tc_TCID", "exception": null, "attachments": []}]', + ), + ) + # Test the --td option + result6 = runner.invoke( + cli.cli, + [ + "prompt", + "-m", + "echo", + "--tool", + "output_as_json", + json.dumps( + { + "tool_calls": [ + {"name": "output_as_json", "arguments": {"text": "hi"}} + ] + } + ), + "--td", + ], + ) + assert result6.exit_code == 0 + assert ( + "Tool call: output_as_json({'text': 'hi'})\n" + " {\n" + ' "this_is_in_json": {\n' + ' "nested": "hi"\n' + " }\n" + " }" + ) in result6.output + finally: + plugins.pm.unregister(name="ToolsPlugin") + + +class Memory(llm.Toolbox): + _memory = None + + def _get_memory(self): + if self._memory is None: + self._memory = {} + return self._memory + + def set(self, key: str, value: str): + "Set something as a key" + self._get_memory()[key] = value + + def get(self, key: str): + "Get something from a key" + return self._get_memory().get(key) or "" + + def append(self, key: str, value: str): + "Append something as a key" + memory = self._get_memory() + memory[key] = (memory.get(key) or "") + "\n" + value + + def keys(self): + "Return a list of keys" + return list(self._get_memory().keys()) + + +class Filesystem(llm.Toolbox): + def __init__(self, path: str): + self.path = path + + async def list_files(self): + # async here just to confirm that works + return [str(item) for item in pathlib.Path(self.path).glob("*")] + + +class ToolboxPlugin: + __name__ = "ToolboxPlugin" + + @hookimpl + def register_tools(self, register): + register(Memory) + register(Filesystem) + + +def test_register_toolbox(tmpdir, logs_db): + # Test the Python API + model = llm.get_model("echo") + memory = Memory() + conversation = model.conversation(tools=[memory]) + accumulated = [] + + def after_call(tool, tool_call, tool_result): + accumulated.append((tool.name, tool_call.arguments, tool_result.output)) + + conversation.chain( + json.dumps( + { + "tool_calls": [ + { + "name": "Memory_set", + "arguments": {"key": "hello", "value": "world"}, + } + ] + } + ), + after_call=after_call, + ).text() + conversation.chain( + json.dumps( + {"tool_calls": [{"name": "Memory_get", "arguments": {"key": "hello"}}]} + ), + after_call=after_call, + ).text() + assert accumulated == [ + ("Memory_set", {"key": "hello", "value": "world"}, "null"), + ("Memory_get", {"key": "hello"}, "world"), + ] + assert memory._memory == {"hello": "world"} + + # And for the Filesystem with state + my_dir = pathlib.Path(tmpdir / "mine") + my_dir.mkdir() + (my_dir / "doc.txt").write_text("hi", "utf-8") + conversation = model.conversation(tools=[Filesystem(my_dir)]) + accumulated.clear() + conversation.chain( + json.dumps( + { + "tool_calls": [ + { + "name": "Filesystem_list_files", + } + ] + } + ), + after_call=after_call, + ).text() + assert accumulated == [ + ("Filesystem_list_files", {}, json.dumps([str(my_dir / "doc.txt")])) + ] + + # Now register them with a plugin and use it through the CLI + try: + plugins.pm.register(ToolboxPlugin(), name="ToolboxPlugin") + tools = llm.get_tools() + assert tools["Memory"] is Memory + + runner = CliRunner() + # llm tools --json + result = runner.invoke(cli.cli, ["tools", "--json"]) + assert result.exit_code == 0 + assert json.loads(result.output) == { + "tools": [ + { + "description": "Returns the current time, as local time and UTC", + "name": "llm_time", + "plugin": "llm.default_plugins.default_tools", + "arguments": { + "properties": {}, + "type": "object", + }, + }, + { + "name": "llm_version", + "description": "Return the installed version of llm", + "arguments": {"properties": {}, "type": "object"}, + "plugin": "llm.default_plugins.default_tools", + }, + ], + "toolboxes": [ + { + "name": "Filesystem", + "tools": [ + { + "name": "Filesystem_list_files", + "description": None, + "arguments": {"properties": {}, "type": "object"}, + } + ], + }, + { + "name": "Memory", + "tools": [ + { + "name": "Memory_append", + "description": "Append something as a key", + "arguments": { + "properties": { + "key": {"type": "string"}, + "value": {"type": "string"}, + }, + "required": ["key", "value"], + "type": "object", + }, + }, + { + "name": "Memory_get", + "description": "Get something from a key", + "arguments": { + "properties": {"key": {"type": "string"}}, + "required": ["key"], + "type": "object", + }, + }, + { + "name": "Memory_keys", + "description": "Return a list of keys", + "arguments": {"properties": {}, "type": "object"}, + }, + { + "name": "Memory_set", + "description": "Set something as a key", + "arguments": { + "properties": { + "key": {"type": "string"}, + "value": {"type": "string"}, + }, + "required": ["key", "value"], + "type": "object", + }, + }, + ], + }, + ], + } + + # llm tools (no JSON) + result = runner.invoke(cli.cli, ["tools"]) + assert result.exit_code == 0 + assert result.output == ( + "llm_time() -> dict (plugin: llm.default_plugins.default_tools)\n\n" + " Returns the current time, as local time and UTC\n\n" + "llm_version() -> str (plugin: llm.default_plugins.default_tools)\n\n" + " Return the installed version of llm\n\n" + "Filesystem:\n\n" + " Filesystem_list_files()\n\n" + "Memory:\n\n" + " Memory_append(key: str, value: str)\n\n" + " Append something as a key\n\n" + " Memory_get(key: str)\n\n" + " Get something from a key\n\n" + " Memory_keys()\n\n" + " Return a list of keys\n\n" + " Memory_set(key: str, value: str)\n\n" + " Set something as a key\n\n" + ) + + # Test the CLI running a toolbox prompt + result3 = runner.invoke( + cli.cli, + [ + "prompt", + "-T", + "Memory", + json.dumps( + { + "tool_calls": [ + { + "name": "Memory_set", + "arguments": {"key": "hi", "value": "two"}, + }, + {"name": "Memory_get", "arguments": {"key": "hi"}}, + ] + } + ), + "-m", + "echo", + ], + ) + assert result3.exit_code == 0 + tool_results = json.loads( + "[" + result3.output.split('"tool_results": [')[1].split("]")[0] + "]" + ) + assert tool_results == [ + {"name": "Memory_set", "output": "null", "tool_call_id": ANY}, + {"name": "Memory_get", "output": "two", "tool_call_id": ANY}, + ] + + # Test the CLI running a configured toolbox prompt + my_dir2 = pathlib.Path(tmpdir / "mine2") + my_dir2.mkdir() + other_path = my_dir2 / "other.txt" + other_path.write_text("hi", "utf-8") + result4 = runner.invoke( + cli.cli, + [ + "prompt", + "-T", + "Filesystem({})".format(json.dumps(str(my_dir2))), + json.dumps({"tool_calls": [{"name": "Filesystem_list_files"}]}), + "-m", + "echo", + ], + ) + assert result4.exit_code == 0 + tool_results = json.loads( + "[" + result4.output.split('"tool_results": [')[1].rsplit("]", 1)[0] + "]" + ) + assert tool_results == [ + { + "name": "Filesystem_list_files", + "output": json.dumps([str(other_path)]), + "tool_call_id": ANY, + } + ] + + # Should show an error if you attempt to llm -c with configured toolboxes + result5 = runner.invoke( + cli.cli, + ["-c", "list them again"], + ) + assert result5.exit_code == 1 + assert ( + "Error: Tool(s) Filesystem_list_files not found. Available tools:" + in result5.output + ) + + # Test the logging worked + rows = list(logs_db.query(TOOL_RESULTS_SQL)) + # JSON decode things in rows + for row in rows: + row["tool_calls"] = json.loads(row["tool_calls"]) + row["tool_results"] = json.loads(row["tool_results"]) + assert rows == [ + { + "model": "echo", + "tool_calls": [ + { + "name": "Memory_set", + "arguments": '{"key": "hi", "value": "two"}', + }, + {"name": "Memory_get", "arguments": '{"key": "hi"}'}, + ], + "tool_results": [], + }, + { + "model": "echo", + "tool_calls": [], + "tool_results": [ + { + "name": "Memory_set", + "output": "null", + "instance": { + "name": "Memory", + "plugin": "ToolboxPlugin", + "arguments": "{}", + }, + }, + { + "name": "Memory_get", + "output": "two", + "instance": { + "name": "Memory", + "plugin": "ToolboxPlugin", + "arguments": "{}", + }, + }, + ], + }, + { + "model": "echo", + "tool_calls": [{"name": "Filesystem_list_files", "arguments": "{}"}], + "tool_results": [], + }, + { + "model": "echo", + "tool_calls": [], + "tool_results": [ + { + "name": "Filesystem_list_files", + "output": json.dumps([str(other_path)]), + "instance": { + "name": "Filesystem", + "plugin": "ToolboxPlugin", + "arguments": json.dumps({"path": str(my_dir2)}), + }, + } + ], + }, + ] + + finally: + plugins.pm.unregister(name="ToolboxPlugin") + + +def test_register_toolbox_fails_on_bad_class(): + class BadTools: + def bad(self): + return "this is bad" + + class BadToolsPlugin: + __name__ = "BadToolsPlugin" + + @hookimpl + def register_tools(self, register): + # This should fail because BadTools is not a subclass of llm.Toolbox + register(BadTools) + + try: + plugins.pm.register(BadToolsPlugin(), name="BadToolsPlugin") + with pytest.raises(TypeError): + llm.get_tools() + finally: + plugins.pm.unregister(name="BadToolsPlugin") + + +def test_toolbox_logging_async(logs_db, tmpdir): + path = pathlib.Path(tmpdir / "path") + path.mkdir() + runner = CliRunner() + try: + plugins.pm.register(ToolboxPlugin(), name="ToolboxPlugin") + + # Run Memory and Filesystem tests --async + result = runner.invoke( + cli.cli, + [ + "prompt", + "--async", + "-T", + "Memory", + "--tool", + "Filesystem({})".format(json.dumps(str(path))), + json.dumps( + { + "tool_calls": [ + { + "name": "Memory_set", + "arguments": {"key": "hi", "value": "two"}, + }, + {"name": "Memory_get", "arguments": {"key": "hi"}}, + {"name": "Filesystem_list_files"}, + ] + } + ), + "-m", + "echo", + ], + ) + assert result.exit_code == 0 + tool_results = json.loads( + "[" + result.output.split('"tool_results": [')[1].rsplit("]", 1)[0] + "]" + ) + assert tool_results == [ + {"name": "Memory_set", "output": "null", "tool_call_id": ANY}, + {"name": "Memory_get", "output": "two", "tool_call_id": ANY}, + {"name": "Filesystem_list_files", "output": "[]", "tool_call_id": ANY}, + ] + finally: + plugins.pm.unregister(name="ToolboxPlugin") + + # Check the database + rows = list(logs_db.query(TOOL_RESULTS_SQL)) + # JSON decode things in rows + for row in rows: + row["tool_calls"] = json.loads(row["tool_calls"]) + row["tool_results"] = json.loads(row["tool_results"]) + assert rows == [ + { + "model": "echo", + "tool_calls": [ + {"name": "Memory_set", "arguments": '{"key": "hi", "value": "two"}'}, + {"name": "Memory_get", "arguments": '{"key": "hi"}'}, + {"name": "Filesystem_list_files", "arguments": "{}"}, + ], + "tool_results": [], + }, + { + "model": "echo", + "tool_calls": [], + "tool_results": [ + { + "name": "Memory_set", + "output": "null", + "instance": { + "name": "Filesystem", + "plugin": "ToolboxPlugin", + "arguments": "{}", + }, + }, + { + "name": "Memory_get", + "output": "two", + "instance": { + "name": "Filesystem", + "plugin": "ToolboxPlugin", + "arguments": "{}", + }, + }, + { + "name": "Filesystem_list_files", + "output": "[]", + "instance": { + "name": "Filesystem", + "plugin": "ToolboxPlugin", + "arguments": json.dumps({"path": str(path)}), + }, + }, + ], + }, + ] + + +def test_plugins_command(): + runner = CliRunner() + result = runner.invoke(cli.cli, ["plugins"]) + assert result.exit_code == 0 + expected = [ + {"name": "EchoModelPlugin", "hooks": ["register_models"]}, + { + "name": "MockModelsPlugin", + "hooks": ["register_embedding_models", "register_models"], + }, + ] + actual = json.loads(result.output) + actual.sort(key=lambda p: p["name"]) + assert actual == expected + # Test the --hook option + result2 = runner.invoke(cli.cli, ["plugins", "--hook", "register_embedding_models"]) + assert result2.exit_code == 0 + assert json.loads(result2.output) == [ + { + "name": "MockModelsPlugin", + "hooks": ["register_embedding_models", "register_models"], + }, + ] + + +TOOL_RESULTS_SQL = """ +-- First, create ordered subqueries for tool_calls and tool_results +with ordered_tool_calls as ( + select + tc.response_id, + json_group_array( + json_object( + 'name', tc.name, + 'arguments', tc.arguments + ) + ) as tool_calls_json + from ( + select * from tool_calls order by id + ) tc + where tc.id is not null + group by tc.response_id +), +ordered_tool_results as ( + select + tr.response_id, + json_group_array( + json_object( + 'name', tr.name, + 'output', tr.output, + 'instance', case + when ti.id is not null then json_object( + 'name', ti.name, + 'plugin', ti.plugin, + 'arguments', ti.arguments + ) + else null + end + ) + ) as tool_results_json + from ( + select distinct tr.*, ti.id as ti_id, ti.name as ti_name, + ti.plugin, ti.arguments as ti_arguments + from tool_results tr + left join tool_instances ti on tr.instance_id = ti.id + order by tr.id + ) tr + left join tool_instances ti on tr.instance_id = ti.id + where tr.id is not null + group by tr.response_id +) +select + r.model, + coalesce(otc.tool_calls_json, '[]') as tool_calls, + coalesce(otr.tool_results_json, '[]') as tool_results +from responses r +left join ordered_tool_calls otc on r.id = otc.response_id +left join ordered_tool_results otr on r.id = otr.response_id +group by r.id, r.model +order by r.id""" diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000..3cc1c86 --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,406 @@ +"""Tests for llm.serialization — the TypedDict spec for the JSON-safe +wire form of Message, Part, and Response. + +Uses pydantic.TypeAdapter to verify that actual to_dict() output +conforms to the TypedDict annotations. pydantic is already a runtime +dependency. +""" + +import json +import pytest +from pydantic import TypeAdapter + +import llm +from llm.serialization import ( + AttachmentPartDict, + MessageDict, + PartDict, + ResponseDict, + ReasoningPartDict, + TextPartDict, + ToolCallPartDict, + ToolResultPartDict, +) + +# ---- required/optional keys ---------------------------------------- + + +class TestRequiredOptionalKeys: + def test_message_dict_required_keys(self): + assert MessageDict.__required_keys__ == {"role", "parts"} + assert MessageDict.__optional_keys__ == {"provider_metadata"} + + def test_text_part_dict_required_keys(self): + assert TextPartDict.__required_keys__ == {"type", "text"} + assert TextPartDict.__optional_keys__ == {"provider_metadata"} + + def test_reasoning_part_dict_required_keys(self): + assert ReasoningPartDict.__required_keys__ == {"type", "text"} + assert ReasoningPartDict.__optional_keys__ == { + "redacted", + "provider_metadata", + } + + def test_tool_call_part_dict_required_keys(self): + assert ToolCallPartDict.__required_keys__ == {"type", "name", "arguments"} + assert ToolCallPartDict.__optional_keys__ == { + "tool_call_id", + "server_executed", + "provider_metadata", + } + + def test_tool_result_part_dict_required_keys(self): + assert ToolResultPartDict.__required_keys__ == {"type", "name", "output"} + assert ToolResultPartDict.__optional_keys__ == { + "tool_call_id", + "server_executed", + "exception", + "attachments", + "provider_metadata", + } + + def test_attachment_part_dict_required_keys(self): + assert AttachmentPartDict.__required_keys__ == {"type"} + assert AttachmentPartDict.__optional_keys__ == { + "attachment", + "provider_metadata", + } + + def test_response_dict_required_keys(self): + assert ResponseDict.__required_keys__ == {"model", "prompt", "messages"} + assert ResponseDict.__optional_keys__ == {"id", "usage", "datetime_utc"} + + +# ---- to_dict output conforms to the TypedDict ---------------------- + + +class TestPartRoundTrip: + def _adapter(self, td): + return TypeAdapter(td) + + def test_text_part_matches(self): + d = llm.parts.TextPart(text="hello").to_dict() + self._adapter(TextPartDict).validate_python(d) + + def test_text_part_with_provider_metadata_matches(self): + d = llm.parts.TextPart( + text="hi", provider_metadata={"anthropic": {"cached": True}} + ).to_dict() + self._adapter(TextPartDict).validate_python(d) + + def test_reasoning_part_redacted_matches(self): + d = llm.parts.ReasoningPart(text="", redacted=True).to_dict() + self._adapter(ReasoningPartDict).validate_python(d) + + def test_reasoning_part_with_signature_matches(self): + d = llm.parts.ReasoningPart( + text="thinking...", + provider_metadata={"anthropic": {"signature": "sig-abc"}}, + ).to_dict() + self._adapter(ReasoningPartDict).validate_python(d) + + def test_tool_call_part_matches(self): + d = llm.parts.ToolCallPart( + name="search", arguments={"q": "x"}, tool_call_id="c1" + ).to_dict() + self._adapter(ToolCallPartDict).validate_python(d) + + def test_tool_result_part_matches(self): + d = llm.parts.ToolResultPart( + name="search", output="result", tool_call_id="c1" + ).to_dict() + self._adapter(ToolResultPartDict).validate_python(d) + + def test_attachment_part_with_url_matches(self): + att = llm.Attachment(type="image/jpeg", url="https://example.com/cat.jpg") + d = llm.parts.AttachmentPart(attachment=att).to_dict() + self._adapter(AttachmentPartDict).validate_python(d) + + def test_attachment_part_with_bytes_matches(self): + att = llm.Attachment(type="image/png", content=b"\x89PNG...") + d = llm.parts.AttachmentPart(attachment=att).to_dict() + self._adapter(AttachmentPartDict).validate_python(d) + + +class TestPartDiscriminatedUnion: + def test_text_part_validates_as_part_dict(self): + d = llm.parts.TextPart(text="hi").to_dict() + TypeAdapter(PartDict).validate_python(d) + + def test_reasoning_part_validates_as_part_dict(self): + d = llm.parts.ReasoningPart(text="thinking").to_dict() + TypeAdapter(PartDict).validate_python(d) + + def test_tool_call_part_validates_as_part_dict(self): + d = llm.parts.ToolCallPart(name="t", arguments={}, tool_call_id="c1").to_dict() + TypeAdapter(PartDict).validate_python(d) + + def test_tool_result_part_validates_as_part_dict(self): + d = llm.parts.ToolResultPart( + name="t", output="out", tool_call_id="c1" + ).to_dict() + TypeAdapter(PartDict).validate_python(d) + + def test_attachment_part_validates_as_part_dict(self): + att = llm.Attachment(type="image/jpeg", url="http://x") + d = llm.parts.AttachmentPart(attachment=att).to_dict() + TypeAdapter(PartDict).validate_python(d) + + def test_unknown_type_rejected(self): + with pytest.raises(Exception): + TypeAdapter(PartDict).validate_python({"type": "nonsense", "text": "x"}) + + +class TestMessageDictRoundTrip: + def test_user_message_matches(self): + d = llm.user("hi").to_dict() + TypeAdapter(MessageDict).validate_python(d) + + def test_assistant_with_mixed_parts_matches(self): + m = llm.Message( + role="assistant", + parts=[ + llm.parts.ReasoningPart( + text="thinking", + provider_metadata={"anthropic": {"signature": "s"}}, + ), + llm.parts.TextPart(text="answer"), + llm.parts.ToolCallPart( + name="search", + arguments={"q": "x"}, + tool_call_id="c1", + ), + ], + ) + TypeAdapter(MessageDict).validate_python(m.to_dict()) + + def test_tool_role_message_with_results_matches(self): + m = llm.tool_message( + llm.parts.ToolResultPart(name="s", output="r", tool_call_id="c1"), + ) + TypeAdapter(MessageDict).validate_python(m.to_dict()) + + +class TestResponseDictRoundTrip: + def test_mock_response_to_dict_matches(self, mock_model): + mock_model.enqueue(["answer"]) + r = mock_model.prompt("q") + r.text() + + d = r.to_dict() + TypeAdapter(ResponseDict).validate_python(d) + + def test_response_with_reasoning_matches(self, mock_model): + mock_model.enqueue( + [ + llm.parts.StreamEvent( + type="reasoning", + chunk="thinking", + part_index=0, + provider_metadata={"anthropic": {"signature": "s"}}, + ), + llm.parts.StreamEvent(type="text", chunk="answer", part_index=1), + ] + ) + r = mock_model.prompt("q") + r.text() + + d = r.to_dict() + TypeAdapter(ResponseDict).validate_python(d) + + def test_response_with_options_matches(self, mock_model): + mock_model.enqueue(["ok"]) + r = mock_model.prompt("q", max_tokens=42) + r.text() + + d = r.to_dict() + TypeAdapter(ResponseDict).validate_python(d) + assert d["prompt"].get("options") == {"max_tokens": 42} + + +# ---- Literal discriminators ---------------------------------------- + + +class TestLiteralDiscriminators: + """The `type` field on each PartDict is a Literal — that's how + Pydantic's discriminated unions work. Verify each literal.""" + + def test_text_part_literal_is_text(self): + import typing + + hints = typing.get_type_hints(TextPartDict) + # Literal["text"] — check the args + assert typing.get_args(hints["type"]) == ("text",) + + def test_reasoning_part_literal_is_reasoning(self): + import typing + + hints = typing.get_type_hints(ReasoningPartDict) + assert typing.get_args(hints["type"]) == ("reasoning",) + + def test_tool_call_part_literal_is_tool_call(self): + import typing + + hints = typing.get_type_hints(ToolCallPartDict) + assert typing.get_args(hints["type"]) == ("tool_call",) + + def test_tool_result_part_literal_is_tool_result(self): + import typing + + hints = typing.get_type_hints(ToolResultPartDict) + assert typing.get_args(hints["type"]) == ("tool_result",) + + def test_attachment_part_literal_is_attachment(self): + import typing + + hints = typing.get_type_hints(AttachmentPartDict) + assert typing.get_args(hints["type"]) == ("attachment",) + + +# ---- to_dict / from_dict return-type annotations ------------------- + + +class TestAnnotations: + """Method signatures should advertise the specific TypedDicts.""" + + def test_text_part_to_dict_annotation(self): + import typing + + hints = typing.get_type_hints(llm.parts.TextPart.to_dict) + assert hints["return"] is TextPartDict + + def test_reasoning_part_to_dict_annotation(self): + import typing + + hints = typing.get_type_hints(llm.parts.ReasoningPart.to_dict) + assert hints["return"] is ReasoningPartDict + + def test_tool_call_part_to_dict_annotation(self): + import typing + + hints = typing.get_type_hints(llm.parts.ToolCallPart.to_dict) + assert hints["return"] is ToolCallPartDict + + def test_tool_result_part_to_dict_annotation(self): + import typing + + hints = typing.get_type_hints(llm.parts.ToolResultPart.to_dict) + assert hints["return"] is ToolResultPartDict + + def test_attachment_part_to_dict_annotation(self): + import typing + + hints = typing.get_type_hints(llm.parts.AttachmentPart.to_dict) + assert hints["return"] is AttachmentPartDict + + def test_message_to_dict_annotation(self): + import typing + + hints = typing.get_type_hints(llm.Message.to_dict) + assert hints["return"] is MessageDict + + def test_message_from_dict_annotation(self): + import typing + + hints = typing.get_type_hints(llm.Message.from_dict) + assert hints["d"] is MessageDict + + def test_response_to_dict_annotation(self): + import typing + + hints = typing.get_type_hints(llm.Response.to_dict) + assert hints["return"] is ResponseDict + + +# ---- End-to-end JSON round-trip validates against schema ----------- + + +class TestEndToEnd: + def test_json_roundtrip_validates(self, mock_model): + mock_model.enqueue(["text answer"]) + r = mock_model.prompt("q") + r.text() + + payload = json.dumps(r.to_dict()) + parsed = json.loads(payload) + # Parsed dict should still conform to ResponseDict. + TypeAdapter(ResponseDict).validate_python(parsed) + + +# ---- to_dict() must not emit keys absent from the TypedDict -------- +# +# pydantic's TypeAdapter on a TypedDict silently drops keys that aren't +# declared, so the round-trip tests above will not catch the case where +# .to_dict() starts emitting a brand-new key that nobody added to the +# TypedDict. These tests close that gap by asserting the set of keys +# .to_dict() returns is a subset of the union of required + optional +# keys declared on the corresponding TypedDict. + + +def _allowed(td): + return td.__required_keys__ | td.__optional_keys__ + + +class TestNoUndeclaredKeys: + def test_text_part_keys(self): + d = llm.parts.TextPart( + text="hi", + provider_metadata={"k": "v"}, + ).to_dict() + assert set(d.keys()) <= _allowed(TextPartDict) + + def test_reasoning_part_keys(self): + d = llm.parts.ReasoningPart( + text="t", + redacted=True, + provider_metadata={"k": "v"}, + ).to_dict() + assert set(d.keys()) <= _allowed(ReasoningPartDict) + + def test_tool_call_part_keys(self): + d = llm.parts.ToolCallPart( + name="t", + arguments={"q": "x"}, + tool_call_id="c1", + server_executed=True, + provider_metadata={"k": "v"}, + ).to_dict() + assert set(d.keys()) <= _allowed(ToolCallPartDict) + + def test_tool_result_part_keys(self): + d = llm.parts.ToolResultPart( + name="t", + output="r", + tool_call_id="c1", + server_executed=True, + exception="boom", + attachments=[llm.Attachment(type="image/png", url="http://x/y.png")], + provider_metadata={"k": "v"}, + ).to_dict() + assert set(d.keys()) <= _allowed(ToolResultPartDict) + + def test_attachment_part_keys(self): + d = llm.parts.AttachmentPart( + attachment=llm.Attachment(type="image/png", url="http://x/y.png"), + provider_metadata={"k": "v"}, + ).to_dict() + assert set(d.keys()) <= _allowed(AttachmentPartDict) + + def test_message_keys(self): + d = llm.Message( + role="assistant", + parts=[llm.parts.TextPart(text="hi")], + provider_metadata={"k": "v"}, + ).to_dict() + assert set(d.keys()) <= _allowed(MessageDict) + + def test_response_keys(self, mock_model): + mock_model.enqueue(["answer"]) + r = mock_model.prompt("q", max_tokens=10) + r.text() + d = r.to_dict() + assert set(d.keys()) <= _allowed(ResponseDict) + # And the nested prompt sub-dict must conform too. + from llm.serialization import PromptDict + + assert set(d["prompt"].keys()) <= _allowed(PromptDict) diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..3822961 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,561 @@ +from click.testing import CliRunner +from importlib.metadata import version +import json +from llm import Template, Toolbox, hookimpl, user_dir +from llm.cli import cli +from llm.plugins import pm +import os +from unittest import mock +import pathlib +import pytest +import textwrap +import yaml + + +@pytest.mark.parametrize( + "prompt,system,defaults,params,expected_prompt,expected_system,expected_error", + ( + ("S: $input", None, None, {}, "S: input", None, None), + ("S: $input", "system", None, {}, "S: input", "system", None), + ("No vars", None, None, {}, "No vars", None, None), + ("$one and $two", None, None, {}, None, None, "Missing variables: one, two"), + ("$one and $two", None, None, {"one": 1, "two": 2}, "1 and 2", None, None), + ("$one and $two", None, {"one": 1}, {"two": 2}, "1 and 2", None, None), + ("$one and $$2", None, None, {"one": 1}, "1 and $2", None, None), + ( + "$one and $two", + None, + {"one": 99}, + {"one": 1, "two": 2}, + "1 and 2", + None, + None, + ), + ), +) +def test_template_evaluate( + prompt, system, defaults, params, expected_prompt, expected_system, expected_error +): + t = Template(name="t", prompt=prompt, system=system, defaults=defaults) + if expected_error: + with pytest.raises(Template.MissingVariables) as ex: + prompt, system = t.evaluate("input", params) + assert ex.value.args[0] == expected_error + else: + prompt, system = t.evaluate("input", params) + assert prompt == expected_prompt + assert system == expected_system + + +def test_templates_list_no_templates_found(): + runner = CliRunner() + result = runner.invoke(cli, ["templates", "list"]) + assert result.exit_code == 0 + assert result.output == "" + + +@pytest.mark.parametrize("args", (["templates", "list"], ["templates"])) +def test_templates_list(templates_path, args): + (templates_path / "one.yaml").write_text("template one", "utf-8") + (templates_path / "two.yaml").write_text("template two", "utf-8") + (templates_path / "three.yaml").write_text( + "template three is very long " * 4, "utf-8" + ) + (templates_path / "four.yaml").write_text( + "'this one\n\nhas newlines in it'", "utf-8" + ) + (templates_path / "both.yaml").write_text( + "system: summarize this\nprompt: $input", "utf-8" + ) + (templates_path / "sys.yaml").write_text("system: Summarize this", "utf-8") + (templates_path / "invalid.yaml").write_text("system2: This is invalid", "utf-8") + runner = CliRunner() + result = runner.invoke(cli, args) + assert result.exit_code == 0 + assert result.output == ( + "both : system: summarize this prompt: $input\n" + "four : this one has newlines in it\n" + "one : template one\n" + "sys : system: Summarize this\n" + "three : template three is very long template three is very long template thre...\n" + "two : template two\n" + ) + + +@pytest.mark.parametrize( + "args,expected,expected_error", + ( + (["-m", "gpt4", "hello"], {"model": "gpt-4", "prompt": "hello"}, None), + (["hello $foo"], {"prompt": "hello $foo"}, None), + (["--system", "system"], {"system": "system"}, None), + (["-t", "template"], None, "--save cannot be used with --template"), + (["--continue"], None, "--save cannot be used with --continue"), + (["--cid", "123"], None, "--save cannot be used with --cid"), + (["--conversation", "123"], None, "--save cannot be used with --cid"), + ( + ["Say hello as $name", "-p", "name", "default-name"], + {"prompt": "Say hello as $name", "defaults": {"name": "default-name"}}, + None, + ), + # Options + ( + ["-o", "temperature", "0.5", "--system", "in french"], + {"system": "in french", "options": {"temperature": 0.5}}, + None, + ), + # -x/--extract should be persisted: + ( + ["--system", "write python", "--extract"], + {"system": "write python", "extract": True}, + None, + ), + # So should schemas (and should not sort properties) + ( + [ + "--schema", + '{"properties": {"b": {"type": "string"}, "a": {"type": "string"}}}', + ], + { + "schema_object": { + "properties": {"b": {"type": "string"}, "a": {"type": "string"}} + } + }, + None, + ), + # And fragments and system_fragments + ( + ["--fragment", "f1.txt", "--system-fragment", "https://example.com/f2.txt"], + { + "fragments": ["f1.txt"], + "system_fragments": ["https://example.com/f2.txt"], + }, + None, + ), + # And attachments and attachment_types + ( + ["--attachment", "a.txt", "--attachment-type", "b.txt", "text/plain"], + { + "attachments": ["a.txt"], + "attachment_types": [{"type": "text/plain", "value": "b.txt"}], + }, + None, + ), + # Model option using an enum: https://github.com/simonw/llm/issues/1237 + ( + ["-m", "gpt-5", "-o", "reasoning_effort", "minimal"], + { + "model": "gpt-5", + "options": {"reasoning_effort": "minimal"}, + }, + None, + ), + ), +) +def test_templates_prompt_save(templates_path, args, expected, expected_error): + assert not (templates_path / "saved.yaml").exists() + runner = CliRunner() + with runner.isolated_filesystem(): + # Create a file to test attachment + pathlib.Path("a.txt").write_text("attachment", "utf-8") + pathlib.Path("b.txt").write_text("attachment type", "utf-8") + result = runner.invoke(cli, args + ["--save", "saved"], catch_exceptions=False) + if not expected_error: + assert result.exit_code == 0 + yaml_data = yaml.safe_load((templates_path / "saved.yaml").read_text("utf-8")) + # Adjust attachment and attachment_types paths to be just the filename + if "attachments" in yaml_data: + yaml_data["attachments"] = [ + os.path.basename(path) for path in yaml_data["attachments"] + ] + for item in yaml_data.get("attachment_types", []): + item["value"] = os.path.basename(item["value"]) + assert yaml_data == expected + else: + assert result.exit_code == 1 + assert expected_error in result.output + + +def test_templates_error_on_missing_schema(templates_path): + runner = CliRunner() + runner.invoke( + cli, ["the-prompt", "--save", "prompt_no_schema"], catch_exceptions=False + ) + # This should complain about no schema + result = runner.invoke( + cli, ["hi", "--schema", "t:prompt_no_schema"], catch_exceptions=False + ) + assert result.output == "Error: Template 'prompt_no_schema' has no schema\n" + # And this is just an invalid template + result2 = runner.invoke( + cli, ["hi", "--schema", "t:bad_template"], catch_exceptions=False + ) + assert result2.output == "Error: Invalid template: bad_template\n" + + +@mock.patch.dict(os.environ, {"OPENAI_API_KEY": "X"}) +@pytest.mark.parametrize( + "template,input_text,extra_args,expected_model,expected_input,expected_error,expected_options", + ( + ( + "'Summarize this: $input'", + "Input text", + [], + "gpt-4o-mini", + "Summarize this: Input text", + None, + None, + ), + ( + "prompt: 'Summarize this: $input'\nmodel: gpt-4", + "Input text", + [], + "gpt-4", + "Summarize this: Input text", + None, + None, + ), + ( + "prompt: 'Summarize this: $input'", + "Input text", + ["-m", "4"], + "gpt-4", + "Summarize this: Input text", + None, + None, + ), + # -s system prompt should over-ride template system prompt + pytest.param( + "boo", + "Input text", + ["-s", "custom system"], + "gpt-4o-mini", + [ + {"role": "system", "content": "custom system"}, + {"role": "user", "content": "boo\nInput text"}, + ], + None, + None, + marks=pytest.mark.httpx_mock(), + ), + pytest.param( + "prompt: 'Say $hello'", + "Input text", + [], + None, + None, + "Error: Missing variables: hello", + None, + marks=pytest.mark.httpx_mock(), + ), + # Template generated prompt should combine with CLI prompt + ( + "prompt: 'Say $hello'", + "Input text", + ["-p", "hello", "Blah"], + "gpt-4o-mini", + "Say Blah\nInput text", + None, + None, + ), + ( + "prompt: 'Say pelican'", + "", + [], + "gpt-4o-mini", + "Say pelican", + None, + None, + ), + # Template with just a system prompt + ( + "system: 'Summarize this'", + "Input text", + [], + "gpt-4o-mini", + [ + {"content": "Summarize this", "role": "system"}, + {"content": "Input text", "role": "user"}, + ], + None, + None, + ), + # Options + ( + "prompt: 'Summarize this: $input'\noptions:\n temperature: 0.5", + "Input text", + [], + "gpt-4o-mini", + "Summarize this: Input text", + None, + {"temperature": 0.5}, + ), + # Should be over-ridden by CLI + ( + "prompt: 'Summarize this: $input'\noptions:\n temperature: 0.5", + "Input text", + ["-o", "temperature", "0.7"], + "gpt-4o-mini", + "Summarize this: Input text", + None, + {"temperature": 0.7}, + ), + ), +) +def test_execute_prompt_with_a_template( + templates_path, + mocked_openai_chat, + template, + input_text, + extra_args, + expected_model, + expected_input, + expected_error, + expected_options, +): + (templates_path / "template.yaml").write_text(template, "utf-8") + runner = CliRunner() + result = runner.invoke( + cli, + ["--no-stream", "-t", "template"] + + ([input_text] if input_text else []) + + extra_args, + catch_exceptions=False, + ) + if isinstance(expected_input, str): + expected_messages = [{"role": "user", "content": expected_input}] + else: + expected_messages = expected_input + + if expected_error is None: + assert result.exit_code == 0 + last_request = mocked_openai_chat.get_requests()[-1] + expected_data = { + "model": expected_model, + "messages": expected_messages, + "stream": False, + } + if expected_options: + expected_data.update(expected_options) + assert json.loads(last_request.content) == expected_data + else: + assert result.exit_code == 1 + assert result.output.strip() == expected_error + mocked_openai_chat.reset() + + +@pytest.mark.parametrize( + "template,expected", + ( + ( + "system: system\nprompt: prompt", + { + "prompt": "prompt", + "system": "system", + "attachments": [], + "stream": True, + "previous": [], + }, + ), + ( + "prompt: |\n This is\n ```\n code to extract\n ```", + { + "prompt": "This is\n```\ncode to extract\n```", + "system": "", + "attachments": [], + "stream": True, + "previous": [], + }, + ), + # Now try that with extract: true + ( + 'extract: true\nprompt: |\n {"raw": "This is\\n```\\ncode to extract\\n```"}', + "code to extract", + ), + ), +) +def test_execute_prompt_from_template_url(httpx_mock, template, expected): + httpx_mock.add_response( + url="https://example.com/prompt.yaml", + method="GET", + text=template, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["-t", "https://example.com/prompt.yaml", "-m", "echo"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + if isinstance(expected, dict): + assert json.loads(result.output.strip()) == expected + else: + assert result.output.strip() == expected + + +def test_execute_prompt_from_template_path(): + runner = CliRunner() + with runner.isolated_filesystem() as temp_dir: + path = pathlib.Path(temp_dir) / "my-template.yaml" + path.write_text("system: system\nprompt: prompt", "utf-8") + result = runner.invoke( + cli, + ["-t", str(path), "-m", "echo"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + assert json.loads(result.output) == { + "prompt": "prompt", + "system": "system", + "attachments": [], + "stream": True, + "previous": [], + } + + +def test_template_respects_cli_extract_flag( + mocked_openai_chat_returning_fenced_code, templates_path +): + (templates_path / "code.yaml").write_text("prompt: Write code", "utf-8") + runner = CliRunner() + result = runner.invoke( + cli, + ["-t", "code", "-m", "gpt-4o-mini", "--key", "x", "-x"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert "```" not in result.output + assert result.output.strip() == "function foo() {\n return 'bar';\n}" + + +FUNCTIONS_EXAMPLE = """ +def greet(name: str) -> str: + return f"Hello, {name}!" +""" + + +class Greeting(Toolbox): + def __init__(self, greeting: str): + self.greeting = greeting + + def greet(self, name: str) -> str: + "Greet name with a greeting" + return f"{self.greeting}, {name}!" + + +class GreetingsPlugin: + __name__ = "GreetingsPlugin" + + @hookimpl + def register_tools(self, register): + register(Greeting) + + +@pytest.mark.parametrize( + "source,expected_tool_success,expected_functions_success", + ( + ("alias", True, True), + ("file", True, True), + # Loaded from URL or plugin = functions: should not work + ("url", True, False), + ("plugin", True, False), + ), +) +def test_tools_in_templates( + source, expected_tool_success, expected_functions_success, httpx_mock, tmpdir +): + template_yaml = textwrap.dedent(""" + name: test + tools: + - llm_version + - Greeting("hi") + functions: | + def demo(): + return "Demo" + """) + args = [] + + def before(): + pass + + def after(): + pass + + if source == "alias": + args = ["-t", "test"] + (user_dir() / "templates").mkdir(parents=True, exist_ok=True) + (user_dir() / "templates" / "test.yaml").write_text(template_yaml, "utf-8") + elif source == "file": + (tmpdir / "test.yaml").write_text(template_yaml, "utf-8") + args = ["-t", str(tmpdir / "test.yaml")] + elif source == "url": + httpx_mock.add_response( + url="https://example.com/test.yaml", + method="GET", + text=template_yaml, + status_code=200, + is_reusable=True, + ) + args = ["-t", "https://example.com/test.yaml"] + elif source == "plugin": + + class LoadTemplatePlugin: + __name__ = "LoadTemplatePlugin" + + @hookimpl + def register_template_loaders(self, register): + register( + "tool-template", + lambda s: Template( + name="tool-template", + tools=["llm_version", 'Greeting("hi")'], + functions=FUNCTIONS_EXAMPLE, + ), + ) + + def before(): + pm.register(LoadTemplatePlugin(), name="test-tools-in-templates") + + def after(): + pm.unregister(name="test-tools-in-templates") + + args = ["-t", "tool-template:"] + + before() + pm.register(GreetingsPlugin(), name="greetings-plugin") + try: + runner = CliRunner() + # Test llm_version, then Greeting, then demo + for tool_call, text, should_be_present in ( + ({"name": "llm_version"}, version("llm"), True), + ( + {"name": "Greeting_greet", "arguments": {"name": "Alice"}}, + "hi, Alice", + expected_tool_success, + ), + ( + {"name": "Greeting_greet", "arguments": {"name": "Bob"}}, + "hi, Bob!", + expected_tool_success, + ), + ({"name": "demo"}, '"output": "Demo"', expected_functions_success), + ): + result = runner.invoke( + cli, + args + + [ + "-m", + "echo", + "--no-stream", + json.dumps({"tool_calls": [tool_call]}), + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + if should_be_present: + assert text in result.output + else: + assert text not in result.output + finally: + after() + pm.unregister(name="greetings-plugin") diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..81d01dc --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,900 @@ +import asyncio +import re +from click.testing import CliRunner +from importlib.metadata import version +import json +import llm +from llm import cli, CancelToolCall +from llm.migrations import migrate +from llm.tools import llm_time +import os +import pytest +import sqlite_utils +import time + +API_KEY = os.environ.get("PYTEST_OPENAI_API_KEY", None) or "badkey" + + +@pytest.mark.vcr +def test_tool_use_basic(vcr): + model = llm.get_model("gpt-4o-mini") + + def multiply(a: int, b: int) -> int: + """Multiply two numbers.""" + return a * b + + chain_response = model.chain("What is 1231 * 2331?", tools=[multiply], key=API_KEY) + + output = "".join(chain_response) + + assert output == "The result of \\( 1231 \\times 2331 \\) is \\( 2,869,461 \\)." + + first, second = chain_response._responses + + assert first.prompt.prompt == "What is 1231 * 2331?" + assert first.prompt.tools[0].name == "multiply" + + assert len(second.prompt.tool_results) == 1 + assert second.prompt.tool_results[0].name == "multiply" + assert second.prompt.tool_results[0].output == "2869461" + + # Test writing to the database + db = sqlite_utils.Database(memory=True) + migrate(db) + chain_response.log_to_db(db) + assert set(db.table_names()).issuperset( + {"tools", "tool_responses", "tool_calls", "tool_results"} + ) + + responses = list(db["responses"].rows) + assert len(responses) == 2 + first_response, second_response = responses + + tools = list(db["tools"].rows) + assert len(tools) == 1 + assert tools[0]["name"] == "multiply" + assert tools[0]["description"] == "Multiply two numbers." + assert tools[0]["plugin"] is None + + tool_results = list(db["tool_results"].rows) + tool_calls = list(db["tool_calls"].rows) + + assert len(tool_calls) == 1 + assert tool_calls[0]["response_id"] == first_response["id"] + assert tool_calls[0]["name"] == "multiply" + assert tool_calls[0]["arguments"] == '{"a": 1231, "b": 2331}' + + assert len(tool_results) == 1 + assert tool_results[0]["response_id"] == second_response["id"] + assert tool_results[0]["output"] == "2869461" + assert tool_results[0]["tool_call_id"] == tool_calls[0]["tool_call_id"] + + +@pytest.mark.vcr +def test_tool_use_chain_of_two_calls(vcr): + model = llm.get_model("gpt-4o-mini") + + def lookup_population(country: str) -> int: + "Returns the current population of the specified fictional country" + return 123124 + + def can_have_dragons(population: int) -> bool: + "Returns True if the specified population can have dragons, False otherwise" + return population > 10000 + + chain_response = model.chain( + "Can the country of Crumpet have dragons? Answer with only YES or NO", + tools=[lookup_population, can_have_dragons], + stream=False, + key=API_KEY, + ) + + output = chain_response.text() + assert output == "YES" + assert len(chain_response._responses) == 3 + + first, second, third = chain_response._responses + assert first.tool_calls()[0].arguments == {"country": "Crumpet"} + assert first.prompt.tool_results == [] + assert second.prompt.tool_results[0].output == "123124" + assert second.tool_calls()[0].arguments == {"population": 123124} + assert third.prompt.tool_results[0].output == "true" + assert third.tool_calls() == [] + + +def test_tool_use_async_tool_function(): + async def hello(): + return "world" + + model = llm.get_model("echo") + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "hello"}]}), tools=[hello] + ) + output = chain_response.text() + # That's two JSON objects separated by '\n}{\n' + bits = output.split("\n}{\n") + assert len(bits) == 2 + objects = [json.loads(bits[0] + "}"), json.loads("{" + bits[1])] + tool_call_id = objects[1]["tool_results"][0]["tool_call_id"] + assert tool_call_id.startswith("tc_") + objects[1]["tool_results"][0]["tool_call_id"] = None + assert objects == [ + {"prompt": "", "system": "", "attachments": [], "stream": True, "previous": []}, + { + "prompt": "", + "system": "", + "attachments": [], + "stream": True, + "previous": [{"prompt": '{"tool_calls": [{"name": "hello"}]}'}], + "tool_results": [ + {"name": "hello", "output": "world", "tool_call_id": None} + ], + }, + ] + + +@pytest.mark.asyncio +async def test_async_tools_run_tools_in_parallel(): + start_timestamps = [] + + start_ns = time.monotonic_ns() + + async def hello(): + start_timestamps.append(("hello", time.monotonic_ns() - start_ns)) + await asyncio.sleep(0.2) + return "world" + + async def hello2(): + start_timestamps.append(("hello2", time.monotonic_ns() - start_ns)) + await asyncio.sleep(0.2) + return "world2" + + model = llm.get_async_model("echo") + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "hello"}, {"name": "hello2"}]}), + tools=[hello, hello2], + ) + output = await chain_response.text() + # That's two JSON objects separated by '\n}{\n' + bits = output.split("\n}{\n") + assert len(bits) == 2 + objects = [json.loads(bits[0] + "}"), json.loads("{" + bits[1])] + ids = [r["tool_call_id"] for r in objects[1]["tool_results"]] + assert all(i.startswith("tc_") for i in ids) + assert len(set(ids)) == 2 + for r in objects[1]["tool_results"]: + r["tool_call_id"] = None + assert objects == [ + {"prompt": "", "system": "", "attachments": [], "stream": True, "previous": []}, + { + "prompt": "", + "system": "", + "attachments": [], + "stream": True, + "previous": [ + {"prompt": '{"tool_calls": [{"name": "hello"}, {"name": "hello2"}]}'} + ], + "tool_results": [ + {"name": "hello", "output": "world", "tool_call_id": None}, + {"name": "hello2", "output": "world2", "tool_call_id": None}, + ], + }, + ] + delta_ns = start_timestamps[1][1] - start_timestamps[0][1] + # They should have run in parallel so it should be less than 0.02s difference + assert delta_ns < (100_000_000 * 0.2) + + +@pytest.mark.asyncio +async def test_async_toolbox(): + class Tools(llm.Toolbox): + def __init__(self): + self.prepared = False + + async def go(self): + await asyncio.sleep(0) + return "This was async" + + async def prepare_async(self): + await asyncio.sleep(0) + self.prepared = True + + instance = Tools() + assert instance.prepared is False + + model = llm.get_async_model("echo") + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "Tools_go"}]}), + tools=[instance], + ) + output = await chain_response.text() + assert '"output": "This was async"' in output + assert instance.prepared is True + + +def test_toolbox_add_tool(): + model = llm.get_model("echo") + + class Tools(llm.Toolbox): + def __init__(self): + self.prepared = False + + def original(self): + return "Original method" + + def prepare(self): + self.prepared = True + + def new_method(): + return "New method" + + tools = Tools() + tools.add_tool(new_method) + assert not tools.prepared + + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "new_method"}]}), + tools=[tools], + ) + output = chain_response.text() + assert '"output": "New method"' in output + assert tools.prepared + + +def test_toolbox_add_tool_with_pass_self(): + model = llm.get_model("echo") + + class Tools(llm.Toolbox): + def __init__(self, hotdog): + self.hotdog = hotdog + + def original(self): + return "Original method" + + def new_method(self): + return self.hotdog + + tools = Tools("doghot") + tools.add_tool(new_method, pass_self=True) + + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "new_method"}]}), + tools=[tools], + ) + output = chain_response.text() + assert '"output": "doghot"' in output + + +@pytest.mark.vcr +def test_conversation_with_tools(vcr): + import llm + + def add(a: int, b: int) -> int: + return a + b + + def multiply(a: int, b: int) -> int: + return a * b + + model = llm.get_model("echo") + conversation = model.conversation(tools=[add, multiply]) + + output1 = conversation.chain( + json.dumps( + {"tool_calls": [{"name": "multiply", "arguments": {"a": 5324, "b": 23233}}]} + ) + ).text() + assert "123692492" in output1 + output2 = conversation.chain( + json.dumps( + { + "tool_calls": [ + {"name": "add", "arguments": {"a": 841758375, "b": 123123}} + ] + } + ) + ).text() + assert "841881498" in output2 + + +def test_default_tool_llm_version(): + runner = CliRunner() + result = runner.invoke( + cli.cli, + [ + "-m", + "echo", + "-T", + "llm_version", + json.dumps({"tool_calls": [{"name": "llm_version"}]}), + ], + ) + assert result.exit_code == 0 + assert '"output": "{}"'.format(version("llm")) in result.output + + +def test_cli_tools_with_options(): + runner = CliRunner() + result = runner.invoke( + cli.cli, + [ + "-m", + "mock", + "-o", + "max_tokens", + "10", + "-T", + "llm_version", + json.dumps({"tool_calls": [{"name": "llm_version"}]}), + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + # It just needs not to crash + # https://github.com/simonw/llm/issues/1233 + + +def test_functions_tool_locals(): + # https://github.com/simonw/llm/issues/1107 + runner = CliRunner() + result = runner.invoke( + cli.cli, + [ + "-m", + "echo", + "--functions", + "my_locals = locals", + "-T", + "llm_version", + json.dumps({"tool_calls": [{"name": "locals"}]}), + ], + ) + assert result.exit_code == 0 + + +def test_default_tool_llm_time(): + runner = CliRunner() + result = runner.invoke( + cli.cli, + [ + "-m", + "echo", + "-T", + "llm_time", + json.dumps({"tool_calls": [{"name": "llm_time"}]}), + ], + ) + assert result.exit_code == 0 + assert "timezone_offset" in result.output + + # Test it by calling it directly + info = llm_time() + assert set(info.keys()) == { + "timezone_offset", + "utc_time_iso", + "local_time", + "local_timezone", + "utc_time", + "is_dst", + } + + +def test_incorrect_tool_usage(): + model = llm.get_model("echo") + + def simple(name: str): + return name + + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "bad_tool"}]}), + tools=[simple], + ) + output = chain_response.text() + assert 'Error: tool \\"bad_tool\\" does not exist' in output + + +def test_tool_returning_attachment(): + model = llm.get_model("echo") + + def return_attachment() -> llm.Attachment: + return llm.ToolOutput( + "Output", + attachments=[ + llm.Attachment( + content=b"This is a test attachment", + type="image/png", + ) + ], + ) + + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "return_attachment"}]}), + tools=[return_attachment], + ) + output = chain_response.text() + assert '"type": "image/png"' in output + assert '"output": "Output"' in output + + +@pytest.mark.asyncio +async def test_async_tool_returning_attachment(): + model = llm.get_async_model("echo") + + async def return_attachment() -> llm.Attachment: + return llm.ToolOutput( + "Output", + attachments=[ + llm.Attachment( + content=b"This is a test attachment", + type="image/png", + ) + ], + ) + + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "return_attachment"}]}), + tools=[return_attachment], + ) + output = await chain_response.text() + assert '"type": "image/png"' in output + assert '"output": "Output"' in output + + +def test_tool_conversation_settings(): + model = llm.get_model("echo") + before_collected = [] + after_collected = [] + + def before(*args): + before_collected.append(args) + + def after(*args): + after_collected.append(args) + + conversation = model.conversation( + tools=[llm_time], before_call=before, after_call=after + ) + # Run two things + conversation.chain(json.dumps({"tool_calls": [{"name": "llm_time"}]})).text() + conversation.chain(json.dumps({"tool_calls": [{"name": "llm_time"}]})).text() + assert len(before_collected) == 2 + assert len(after_collected) == 2 + + +@pytest.mark.asyncio +async def test_tool_conversation_settings_async(): + model = llm.get_async_model("echo") + before_collected = [] + after_collected = [] + + async def before(*args): + before_collected.append(args) + + async def after(*args): + after_collected.append(args) + + conversation = model.conversation( + tools=[llm_time], before_call=before, after_call=after + ) + await conversation.chain(json.dumps({"tool_calls": [{"name": "llm_time"}]})).text() + await conversation.chain(json.dumps({"tool_calls": [{"name": "llm_time"}]})).text() + assert len(before_collected) == 2 + assert len(after_collected) == 2 + + +ERROR_FUNCTION = """ +def trigger_error(msg: str): + raise Exception(msg) +""" + + +@pytest.mark.parametrize("async_", (False, True)) +def test_tool_errors(async_): + # https://github.com/simonw/llm/issues/1107 + runner = CliRunner() + result = runner.invoke( + cli.cli, + ( + [ + "-m", + "echo", + "--functions", + ERROR_FUNCTION, + json.dumps( + { + "tool_calls": [ + {"name": "trigger_error", "arguments": {"msg": "Error!"}} + ] + } + ), + ] + + (["--async"] if async_ else []) + ), + ) + assert result.exit_code == 0 + assert '"output": "Error: Error!"' in result.output + # llm logs --json output + log_json_result = runner.invoke(cli.cli, ["logs", "--json", "-c"]) + assert log_json_result.exit_code == 0 + log_data = json.loads(log_json_result.output) + assert len(log_data) == 2 + assert log_data[1]["tool_results"][0]["exception"] == "Exception: Error!" + # llm logs -c output + log_text_result = runner.invoke(cli.cli, ["logs", "-c"]) + assert log_text_result.exit_code == 0 + normalized_log_text = re.sub(r"tc_[0-9a-z]{26}", "tc_TCID", log_text_result.output) + assert ( + "- **trigger_error**: `tc_TCID`
\n" + " ```\n" + " Error: Error!\n" + " ```
\n" + " **Error**: Exception: Error!\n" + ) in normalized_log_text + + +def test_chain_sync_cancel_only_first_of_two(): + model = llm.get_model("echo") + + def t1() -> str: + return "ran1" + + def t2() -> str: + return "ran2" + + def before(tool, tool_call): + if tool.name == "t1": + raise CancelToolCall("skip1") + # allow t2 + return None + + calls = [ + {"name": "t1"}, + {"name": "t2"}, + ] + payload = json.dumps({"tool_calls": calls}) + chain = model.chain(payload, tools=[t1, t2], before_call=before) + _ = chain.text() + + # second response has two results + second = chain._responses[1] + results = second.prompt.tool_results + assert len(results) == 2 + + # first cancelled, second executed + assert results[0].name == "t1" + assert results[0].output == "Cancelled: skip1" + assert isinstance(results[0].exception, CancelToolCall) + + assert results[1].name == "t2" + assert results[1].output == "ran2" + assert results[1].exception is None + + +# 2c async equivalent +@pytest.mark.asyncio +async def test_chain_async_cancel_only_first_of_two(): + async_model = llm.get_async_model("echo") + + def t1() -> str: + return "ran1" + + async def t2() -> str: + return "ran2" + + async def before(tool, tool_call): + if tool.name == "t1": + raise CancelToolCall("skip1") + return None + + calls = [ + {"name": "t1"}, + {"name": "t2"}, + ] + payload = json.dumps({"tool_calls": calls}) + chain = async_model.chain(payload, tools=[t1, t2], before_call=before) + _ = await chain.text() + + second = chain._responses[1] + results = second.prompt.tool_results + assert len(results) == 2 + + assert results[0].name == "t1" + assert results[0].output == "Cancelled: skip1" + assert isinstance(results[0].exception, CancelToolCall) + + assert results[1].name == "t2" + assert results[1].output == "ran2" + assert results[1].exception is None + + +def test_tool_function_receives_llm_tool_call(): + captured = {} + + def lookup(name: str, llm_tool_call) -> str: + "Look up a name" + captured["tool_call"] = llm_tool_call + return "result for " + name + + model = llm.get_model("echo") + chain_response = model.chain( + json.dumps( + {"tool_calls": [{"name": "lookup", "arguments": {"name": "simon"}}]} + ), + tools=[lookup], + ) + chain_response.text() + + tool_call = captured["tool_call"] + assert isinstance(tool_call, llm.ToolCall) + assert tool_call.name == "lookup" + assert tool_call.arguments == {"name": "simon"} + second = chain_response._responses[1] + assert second.prompt.tool_results[0].output == "result for simon" + + +def test_async_tool_function_receives_llm_tool_call_with_sync_model(): + captured = {} + + async def lookup(name: str, llm_tool_call: llm.ToolCall) -> str: + "Look up a name" + captured["tool_call"] = llm_tool_call + return "result for " + name + + model = llm.get_model("echo") + chain_response = model.chain( + json.dumps( + {"tool_calls": [{"name": "lookup", "arguments": {"name": "simon"}}]} + ), + tools=[lookup], + ) + chain_response.text() + + tool_call = captured["tool_call"] + assert isinstance(tool_call, llm.ToolCall) + assert tool_call.name == "lookup" + assert tool_call.arguments == {"name": "simon"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("async_tool", (False, True)) +async def test_tool_function_receives_llm_tool_call_async_model(async_tool): + captured = {} + + def lookup(name: str, llm_tool_call) -> str: + "Look up a name" + captured["tool_call"] = llm_tool_call + return "result for " + name + + async def async_lookup(name: str, llm_tool_call) -> str: + "Look up a name" + captured["tool_call"] = llm_tool_call + return "result for " + name + + fn = async_lookup if async_tool else lookup + model = llm.get_async_model("echo") + chain_response = model.chain( + json.dumps( + {"tool_calls": [{"name": fn.__name__, "arguments": {"name": "simon"}}]} + ), + tools=[fn], + ) + output = await chain_response.text() + assert '"output": "result for simon"' in output + + tool_call = captured["tool_call"] + assert isinstance(tool_call, llm.ToolCall) + assert tool_call.name == fn.__name__ + assert tool_call.arguments == {"name": "simon"} + + +def test_llm_tool_call_excluded_from_input_schema(): + def lookup(name: str, llm_tool_call) -> str: + "Look up a name" + return name + + tool = llm.Tool.function(lookup) + assert "llm_tool_call" not in tool.input_schema.get("properties", {}) + assert "llm_tool_call" not in tool.input_schema.get("required", []) + assert "name" in tool.input_schema["properties"] + + +def test_kwargs_only_function_does_not_receive_llm_tool_call(): + # A tool that accepts **kwargs but does not name llm_tool_call + # explicitly should NOT have it injected. + captured = {} + + async def impl(**kwargs): + captured.update(kwargs) + return "ok" + + tool = llm.Tool( + name="t", + description="A tool", + input_schema={"type": "object", "properties": {"name": {"type": "string"}}}, + implementation=impl, + ) + model = llm.get_model("echo") + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "t", "arguments": {"name": "x"}}]}), + tools=[tool], + ) + chain_response.text() + assert captured == {"name": "x"} + + +def test_toolbox_method_receives_llm_tool_call(): + captured = {} + + class Tools(llm.Toolbox): + def lookup(self, name: str, llm_tool_call) -> str: + captured["tool_call"] = llm_tool_call + return "hi " + name + + model = llm.get_model("echo") + chain_response = model.chain( + json.dumps( + {"tool_calls": [{"name": "Tools_lookup", "arguments": {"name": "simon"}}]} + ), + tools=[Tools()], + ) + output = chain_response.text() + assert '"output": "hi simon"' in output + + tool_call = captured["tool_call"] + assert isinstance(tool_call, llm.ToolCall) + assert tool_call.arguments == {"name": "simon"} + + +def test_add_tool_call_synthesizes_missing_tool_call_id(): + model = llm.get_model("echo") + response = model.prompt("hello") + response.add_tool_call(llm.ToolCall(name="a", arguments={})) + response.add_tool_call(llm.ToolCall(name="b", arguments={}, tool_call_id="given")) + response.add_tool_call(llm.ToolCall(name="c", arguments={})) + ids = [tc.tool_call_id for tc in response._tool_calls] + assert ids[0] is not None and ids[0].startswith("tc_") + assert ids[1] == "given" + assert ids[2] is not None and ids[2].startswith("tc_") + assert ids[0] != ids[2] + + +def test_tool_call_ids_guaranteed_through_chain(): + seen_before_call = [] + captured = {} + + def first(llm_tool_call) -> str: + captured["first_id"] = llm_tool_call.tool_call_id + return "one" + + def second() -> str: + return "two" + + def before(tool, tool_call): + seen_before_call.append(tool_call.tool_call_id) + + model = llm.get_model("echo") + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "first"}, {"name": "second"}]}), + tools=[first, second], + before_call=before, + ) + chain_response.text() + + assert len(seen_before_call) == 2 + assert all(i is not None and i.startswith("tc_") for i in seen_before_call) + assert seen_before_call[0] != seen_before_call[1] + # The implementation saw the same id via llm_tool_call + assert captured["first_id"] == seen_before_call[0] + + # ToolResults and the next prompt's tool message carry the same ids + second_response = chain_response._responses[1] + result_ids = [r.tool_call_id for r in second_response.prompt.tool_results] + assert result_ids == seen_before_call + + # The assistant message parts carry the synthesized ids too, so a + # persisted-and-replayed history stays correlated + from llm.parts import ToolCallPart + + first_response = chain_response._responses[0] + part_ids = [ + p.tool_call_id + for p in first_response._messages_now()[0].parts + if isinstance(p, ToolCallPart) + ] + assert part_ids == seen_before_call + + +@pytest.mark.asyncio +async def test_tool_call_ids_guaranteed_async_model(): + seen = [] + + async def hello() -> str: + return "world" + + async def before(tool, tool_call): + seen.append(tool_call.tool_call_id) + + model = llm.get_async_model("echo") + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "hello"}]}), + tools=[hello], + before_call=before, + ) + await chain_response.text() + assert len(seen) == 1 + assert seen[0] is not None and seen[0].startswith("tc_") + + +@pytest.mark.asyncio +async def test_async_missing_tool_produces_error_result(): + # Async executor parity with sync: a call to a tool that is not in + # tools= must produce an error ToolResult, not silently vanish - + # otherwise the next provider call has a tool_call with no result. + before_calls = [] + + async def real_tool() -> str: + return "ok" + + async def before(tool, tool_call): + # before_call fires even when tool is None, like the sync path + before_calls.append((tool.name if tool else None, tool_call.name)) + + model = llm.get_async_model("echo") + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "missing_tool"}, {"name": "real_tool"}]}), + tools=[real_tool], + before_call=before, + ) + await chain_response.text() + + second = chain_response._responses[1] + results = [(r.name, r.output) for r in second.prompt.tool_results] + assert results == [ + ("missing_tool", 'Error: tool "missing_tool" does not exist'), + ("real_tool", "ok"), + ] + assert isinstance(second.prompt.tool_results[0].exception, KeyError) + assert (None, "missing_tool") in before_calls + + +@pytest.mark.asyncio +async def test_async_missing_tool_can_be_cancelled_by_before_call(): + async def real_tool() -> str: + return "ok" + + async def before(tool, tool_call): + if tool is None: + raise CancelToolCall("no such tool") + + model = llm.get_async_model("echo") + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "missing_tool"}, {"name": "real_tool"}]}), + tools=[real_tool], + before_call=before, + ) + await chain_response.text() + second = chain_response._responses[1] + results = [(r.name, r.output) for r in second.prompt.tool_results] + assert results == [ + ("missing_tool", "Cancelled: no such tool"), + ("real_tool", "ok"), + ] + + +@pytest.mark.asyncio +async def test_async_tool_without_implementation_produces_error_result(): + tool = llm.Tool( + name="no_impl", + description="A tool with no implementation", + input_schema={"type": "object", "properties": {}}, + implementation=None, + ) + model = llm.get_async_model("echo") + chain_response = model.chain( + json.dumps({"tool_calls": [{"name": "no_impl"}]}), + tools=[tool], + ) + await chain_response.text() + second = chain_response._responses[1] + assert [(r.name, r.output) for r in second.prompt.tool_results] == [ + ("no_impl", 'Error: tool "no_impl" has no implementation'), + ] diff --git a/tests/test_tools_streaming.py b/tests/test_tools_streaming.py new file mode 100644 index 0000000..87a27ac --- /dev/null +++ b/tests/test_tools_streaming.py @@ -0,0 +1,51 @@ +import llm +from llm.tools import llm_version +import os +import pytest + +API_KEY = os.environ.get("PYTEST_OPENAI_API_KEY", None) or "badkey" + + +# This response contains streaming variant "a" where arguments="" is followed by arguments="{}" +@pytest.mark.vcr(record_mode="none") +def test_tools_streaming_variant_a(): + model = llm.get_model("gpt-4.1-mini") + chain = model.chain( + "What is the current llm version?", tools=[llm_version], key=API_KEY + ) + assert "".join(chain) == "The current version of *llm* is **0.fixed-version**." + + +# This response contains streaming variant "b" where arguments="{}" is the first partial stream received. +@pytest.mark.vcr(record_mode="none") +def test_tools_streaming_variant_b(): + model = llm.get_model("gpt-4.1-mini") + chain = model.chain( + "What is the current llm version?", tools=[llm_version], key=API_KEY + ) + assert "".join(chain) == "The current version of *llm* is **0.fixed-version**." + + +# This response contains streaming variant "c". +@pytest.mark.vcr(record_mode="none") +def test_tools_streaming_variant_c(): + model = llm.get_model("gpt-4.1-mini") + chain = model.chain( + "What is the current llm version?", tools=[llm_version], key=API_KEY + ) + assert ( + "".join(chain) + == "The installed version of LLM on this system is 0.fixed-version." + ) + + +# This response contains streaming variant "d" where a no-argument tool call +# streams arguments=null and never sends a "{}" chunk, so the accumulated +# arguments string stays empty - json.loads("") used to raise here. +@pytest.mark.vcr(record_mode="none") +def test_tools_streaming_variant_d(): + model = llm.get_model("gpt-4.1-mini") + chain = model.chain( + "What is the current llm version?", tools=[llm_version], key=API_KEY + ) + assert "".join(chain) == "The current version of *llm* is **0.fixed-version**." diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..51fb875 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,518 @@ +import json +import pytest +from llm.utils import ( + extract_fenced_code_block, + instantiate_from_spec, + maybe_fenced_code, + schema_dsl, + simplify_usage_dict, + truncate_string, + monotonic_ulid, +) +from llm import get_key, Toolbox + + +@pytest.mark.parametrize( + "input_data,expected_output", + [ + ( + { + "prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0}, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 1, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0, + }, + }, + {"completion_tokens_details": {"audio_tokens": 1}}, + ), + ( + { + "details": {"tokens": 5, "audio_tokens": 2}, + "more_details": {"accepted_tokens": 3}, + }, + { + "details": {"tokens": 5, "audio_tokens": 2}, + "more_details": {"accepted_tokens": 3}, + }, + ), + ({"details": {"tokens": 0, "audio_tokens": 0}, "more_details": {}}, {}), + ({"level1": {"level2": {"value": 0, "another_value": {}}}}, {}), + ( + { + "level1": {"level2": {"value": 0, "another_value": 1}}, + "level3": {"empty_dict": {}, "valid_token": 10}, + }, + {"level1": {"level2": {"another_value": 1}}, "level3": {"valid_token": 10}}, + ), + ], +) +def test_simplify_usage_dict(input_data, expected_output): + # This utility function is used by at least one plugin - llm-openai-plugin + assert simplify_usage_dict(input_data) == expected_output + + +@pytest.mark.parametrize( + "input,last,expected", + [ + ["This is a sample text without any code blocks.", False, None], + [ + "Here is some text.\n\n```\ndef foo():\n return 'bar'\n```\n\nMore text.", + False, + "def foo():\n return 'bar'\n", + ], + [ + "Here is some text.\n\n```python\ndef foo():\n return 'bar'\n```\n\nMore text.", + False, + "def foo():\n return 'bar'\n", + ], + [ + "Here is some text.\n\n````\ndef foo():\n return 'bar'\n````\n\nMore text.", + False, + "def foo():\n return 'bar'\n", + ], + [ + "Here is some text.\n\n````javascript\nfunction foo() {\n return 'bar';\n}\n````\n\nMore text.", + False, + "function foo() {\n return 'bar';\n}\n", + ], + [ + "Here is some text.\n\n```python\ndef foo():\n return 'bar'\n````\n\nMore text.", + False, + None, + ], + [ + "First code block:\n\n```python\ndef foo():\n return 'bar'\n```\n\n" + "Second code block:\n\n```javascript\nfunction foo() {\n return 'bar';\n}\n```", + False, + "def foo():\n return 'bar'\n", + ], + [ + "First code block:\n\n```python\ndef foo():\n return 'bar'\n```\n\n" + "Second code block:\n\n```javascript\nfunction foo() {\n return 'bar';\n}\n```", + True, + "function foo() {\n return 'bar';\n}\n", + ], + [ + "First code block:\n\n```python\ndef foo():\n return 'bar'\n```\n\n" + # This one has trailing whitespace after the second code block: + # https://github.com/simonw/llm/pull/718#issuecomment-2613177036 + "Second code block:\n\n```javascript\nfunction foo() {\n return 'bar';\n}\n``` ", + True, + "function foo() {\n return 'bar';\n}\n", + ], + [ + "Here is some text.\n\n```python\ndef foo():\n return `bar`\n```\n\nMore text.", + False, + "def foo():\n return `bar`\n", + ], + ], +) +def test_extract_fenced_code_block(input, last, expected): + actual = extract_fenced_code_block(input, last=last) + assert actual == expected + + +@pytest.mark.parametrize( + "schema, expected", + [ + # Test case 1: Basic comma-separated fields, default string type + ( + "name, bio", + { + "type": "object", + "properties": {"name": {"type": "string"}, "bio": {"type": "string"}}, + "required": ["name", "bio"], + }, + ), + # Test case 2: Comma-separated fields with types + ( + "name, age int, balance float, active bool", + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "balance": {"type": "number"}, + "active": {"type": "boolean"}, + }, + "required": ["name", "age", "balance", "active"], + }, + ), + # Test case 3: Comma-separated fields with descriptions + ( + "name: full name, age int: years old", + { + "type": "object", + "properties": { + "name": {"type": "string", "description": "full name"}, + "age": {"type": "integer", "description": "years old"}, + }, + "required": ["name", "age"], + }, + ), + # Test case 4: Newline-separated fields + ( + """ + name + bio + age int + """, + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "bio": {"type": "string"}, + "age": {"type": "integer"}, + }, + "required": ["name", "bio", "age"], + }, + ), + # Test case 5: Newline-separated with descriptions containing commas + ( + """ + name: the person's name + age int: their age in years, must be positive + bio: a short bio, no more than three sentences + """, + { + "type": "object", + "properties": { + "name": {"type": "string", "description": "the person's name"}, + "age": { + "type": "integer", + "description": "their age in years, must be positive", + }, + "bio": { + "type": "string", + "description": "a short bio, no more than three sentences", + }, + }, + "required": ["name", "age", "bio"], + }, + ), + # Test case 6: Empty schema + ("", {"type": "object", "properties": {}, "required": []}), + # Test case 7: Explicit string type + ( + "name str, description str", + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "description": {"type": "string"}, + }, + "required": ["name", "description"], + }, + ), + # Test case 8: Extra whitespace + ( + " name , age int : person's age ", + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer", "description": "person's age"}, + }, + "required": ["name", "age"], + }, + ), + ], +) +def test_schema_dsl(schema, expected): + result = schema_dsl(schema) + assert result == expected + + +def test_schema_dsl_multi(): + result = schema_dsl("name, age int: The age", multi=True) + assert result == { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer", "description": "The age"}, + }, + "required": ["name", "age"], + }, + } + }, + "required": ["items"], + } + + +@pytest.mark.parametrize( + "text, max_length, normalize_whitespace, keep_end, expected", + [ + # Basic truncation tests + ("Hello, world!", 100, False, False, "Hello, world!"), + ("Hello, world!", 5, False, False, "He..."), + ("", 10, False, False, ""), + (None, 10, False, False, None), + # Normalize whitespace tests + ("Hello world!", 100, True, False, "Hello world!"), + ("Hello \n\t world!", 100, True, False, "Hello world!"), + ("Hello world!", 5, True, False, "He..."), + # Keep end tests + ("Hello, world!", 10, False, True, "He... d!"), + ("Hello, world!", 7, False, False, "Hell..."), # Now using regular truncation + ("1234567890", 7, False, False, "1234..."), # Now using regular truncation + # Combinations of parameters + ("Hello world!", 10, True, True, "He... d!"), + # Note: After normalization, "Hello world!" is exactly 12 chars, so no truncation + ("Hello \n\t world!", 12, True, True, "Hello world!"), + # Edge cases + ("12345", 5, False, False, "12345"), + ("123456", 5, False, False, "12..."), + ("12345", 5, False, True, "12345"), # Unchanged for exact fit + ("123456", 5, False, False, "12..."), # Regular truncation for small max_length + # Very long string + ("A" * 200, 10, False, False, "AAAAAAA..."), + ("A" * 200, 10, False, True, "AA... AA"), # keep_end with adequate length + # Exact boundary cases + ("123456789", 9, False, False, "123456789"), # Exact fit + ("1234567890", 9, False, False, "123456..."), # Simple truncation + ("123456789", 9, False, True, "123456789"), # Exact fit with keep_end + ("1234567890", 9, False, True, "12... 90"), # keep_end truncation + # Minimum sensible length tests for keep_end + ( + "1234567890", + 8, + False, + True, + "12345...", + ), # Too small for keep_end, use regular + ("1234567890", 9, False, True, "12... 90"), # Just enough for keep_end + ], +) +def test_truncate_string(text, max_length, normalize_whitespace, keep_end, expected): + """Test the truncate_string function with various inputs and parameters.""" + result = truncate_string( + text=text, + max_length=max_length, + normalize_whitespace=normalize_whitespace, + keep_end=keep_end, + ) + assert result == expected + + +@pytest.mark.parametrize( + "text, max_length, keep_end, prefix_len, expected_full", + [ + # Test cases when the length is just right (string fits) + ("0123456789", 10, True, None, "0123456789"), + # Test cases with enough room for the ellipsis + ("012345678901234", 14, True, 4, "0123... 1234"), + # Test cases with different cutoffs + ("abcdefghijklmnopqrstuvwxyz", 10, True, 2, "ab... yz"), + ("abcdefghijklmnopqrstuvwxyz", 12, True, 3, "abc... xyz"), + # Test cases below minimum threshold + ("abcdefghijklmnopqrstuvwxyz", 8, True, None, "abcde..."), + ], +) +def test_test_truncate_string_keep_end( + text, max_length, keep_end, prefix_len, expected_full +): + """Test the specific behavior of the keep_end parameter.""" + result = truncate_string( + text=text, + max_length=max_length, + keep_end=keep_end, + ) + + assert result == expected_full + + # Only check prefix/suffix when we expect truncation with keep_end + if prefix_len is not None and len(text) > max_length and max_length >= 9: + assert result[:prefix_len] == text[:prefix_len] + assert result[-prefix_len:] == text[-prefix_len:] + assert "... " in result + + +@pytest.mark.parametrize( + "content,expected_fenced", + [ + # Case 1: Contains many angle brackets (>10) + ( + "

Test

TestTestTestTestTest", + True, + ), + # Case 2: Short content with few angle brackets + ("

Just a paragraph

", False), + # Case 3: Many short lines (>3 lines, 90% under 120 chars) + ("line1\nline2\nline3\nline4\nline5", True), + # Case 4: Many long lines (>3 lines, <90% under 120 chars) + ("x" * 130 + "\n" + "x" * 130 + "\n" + "x" * 130 + "\n" + "x" * 50, False), + # Case 5: Mixed case (many angle brackets and short lines) + ("
\n

Line 1

\n

Line 2

\n

Line 3

\n
", True), + # Case 6: Mixed case with few lines + ("

Only two

", False), + # Case 7: Empty string + ("", False), + # Case 8: Content with existing backticks (should use more backticks) + ("```\ndef test():\n pass\n```", True), + ], +) +def test_maybe_fenced_code(content: str, expected_fenced: bool): + result = maybe_fenced_code(content) + + if expected_fenced: + # Should be wrapped in fenced code block + assert result != content + assert result.strip().startswith("```") + assert result.strip().endswith("```") + assert content.strip() in result + else: + # Should remain unchanged + assert result == content + + +@pytest.mark.parametrize( + "content,backtick_count", + [ + # Content with no backticks should use 3 backticks + ("def test():\n pass", 3), + # Content with 3 backticks should use 4 backticks + ("```\ndef test():\n pass\n```", 4), + # Content with 4 backticks should use 5 backticks + ("````\ndef test():\n pass\n````", 5), + ], +) +def test_backtick_count_adjustment(content: str, backtick_count: int): + # Force the content to be treated as code by adding many angle brackets + content_with_brackets = content + "<" * 11 + + result = maybe_fenced_code(content_with_brackets) + + # Check if the correct number of backticks is used + expected_start = "\n" + "`" * backtick_count + "\n" + expected_end = "\n" + "`" * backtick_count + + assert result.startswith(expected_start) + assert result.endswith(expected_end) + + +class Files: + def __init__(self, dir="."): + self.dir = dir + + +class ValueFlag: + def __init__(self, value=None, flag=False): + self.value = value + self.flag = flag + + +@pytest.mark.parametrize( + "spec, expected_cls, expected_attrs", + [ + ("Files", Files, {"dir": "."}), + ("Files()", Files, {"dir": "."}), + ('Files("tmp")', Files, {"dir": "tmp"}), + ('Files({"dir": "/tmp"})', Files, {"dir": "/tmp"}), + ('Files(dir="/data")', Files, {"dir": "/data"}), + ( + 'ValueFlag({"value": 123, "flag": true})', + ValueFlag, + {"value": 123, "flag": True}, + ), + ("ValueFlag(flag=true)", ValueFlag, {"flag": True}), + ("ValueFlag(value=123, flag=false)", ValueFlag, {"value": 123, "flag": False}), + ], +) +def test_instantiate_valid(spec, expected_cls, expected_attrs): + obj = instantiate_from_spec({"Files": Files, "ValueFlag": ValueFlag}, spec) + assert isinstance(obj, expected_cls) + for key, val in expected_attrs.items(): + assert getattr(obj, key) == val + + +@pytest.mark.parametrize( + "spec", + [ + 'Files({"dir":})', + "Files(", + "Files(dir=)", + 'Files({"dir": [})', + "Files(.)", + "Files(this is invalid)", + "ValueFlag(value=123, flag=falseTypo)", + ], +) +def test_instantiate_invalid(spec): + with pytest.raises(ValueError): + instantiate_from_spec({"Files": Files, "ValueFlag": ValueFlag}, spec) + + +def test_get_key(user_path, monkeypatch): + monkeypatch.setenv("ENV", "from-env") + (user_path / "keys.json").write_text(json.dumps({"testkey": "TEST"}), "utf-8") + assert get_key(alias="testkey") == "TEST" + assert get_key(input="testkey") == "TEST" + assert get_key(alias="missing", env="ENV") == "from-env" + assert get_key(alias="missing") is None + # found key should over-ride env + assert get_key(input="testkey", env="ENV") == "TEST" + # explicit key should over-ride alias + assert get_key(input="explicit", alias="testkey") == "explicit" + assert get_key(input="explicit", alias="testkey", env="ENV") == "explicit" + + +def test_monotonic_ulids(): + ulids = [monotonic_ulid() for i in range(1000)] + assert ulids == sorted(ulids) + + +def test_toolbox_config_capture(): + """Test that Toolbox captures __init__ parameters in _config""" + + # Single positional arg + class Tool1(Toolbox): + def __init__(self, value): + pass + + assert Tool1(42)._config == {"value": 42} + + # Multiple positional args + class Tool2(Toolbox): + def __init__(self, a, b, c): + pass + + assert Tool2(1, 2, 3)._config == {"a": 1, "b": 2, "c": 3} + + # Keyword args with defaults + class Tool3(Toolbox): + def __init__(self, name="default", count=10): + pass + + assert Tool3()._config == {"name": "default", "count": 10} + assert Tool3(name="custom", count=20)._config == {"name": "custom", "count": 20} + + # Mixed args + class Tool4(Toolbox): + def __init__(self, required, optional="default"): + pass + + assert Tool4("hello")._config == {"required": "hello", "optional": "default"} + assert Tool4("world", optional="custom")._config == { + "required": "world", + "optional": "custom", + } + + # Var args excluded + class Tool5(Toolbox): + def __init__(self, regular, *args, **kwargs): + pass + + assert Tool5("test", 1, 2, extra="value")._config == {"regular": "test"} + + # No init + class Tool6(Toolbox): + pass + + assert Tool6()._config == {}