[
  {
    "path": ".github/FUNDING.yml",
    "content": "github: [simonw]\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n- package-ecosystem: pip\n  directory: \"/\"\n  schedule:\n    interval: daily\n  groups:\n    python-packages:\n      patterns:\n        - \"*\"\n"
  },
  {
    "path": ".github/workflows/cog.yml",
    "content": "name: Run Cog\n\non:\n  pull_request:\n    types: [opened, synchronize]\n\npermissions:\n  contents: write\n  pull-requests: write\n\njobs:\n  run-cog:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          ref: ${{ github.head_ref }}\n\n      - name: Set up Python 3.11\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.11'\n\n      - name: Install dependencies\n        run: |\n          pip install . --group dev\n\n      - name: Run cog\n        run: |\n          cog -r -p \"import sys, os; sys._called_from_test=True; os.environ['LLM_USER_PATH'] = '/tmp'\" docs/**/*.md docs/*.md README.md\n\n      - name: Check for changes\n        id: check-changes\n        run: |\n          if [ -n \"$(git diff)\" ]; then\n            echo \"changes=true\" >> $GITHUB_OUTPUT\n          else\n            echo \"changes=false\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Commit and push if changed\n        if: steps.check-changes.outputs.changes == 'true'\n        run: |\n          git config --local user.email \"github-actions[bot]@users.noreply.github.com\"\n          git config --local user.name \"github-actions[bot]\"\n          git add -A\n          git commit -m \"Ran cog\"\n          git push\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish Python Package\n\non:\n  release:\n    types: [created]\n\npermissions:\n  contents: read\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [\"3.10\", \"3.11\", \"3.12\", \"3.13\", \"3.14\"]\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python ${{ matrix.python-version }}\n      uses: actions/setup-python@v5\n      with:\n        python-version: ${{ matrix.python-version }}\n        cache: pip\n        cache-dependency-path: setup.py\n    - name: Install dependencies\n      run: |\n        pip install . --group dev\n    - name: Run tests\n      run: |\n        pytest\n  deploy:\n    runs-on: ubuntu-latest\n    environment: release\n    permissions:\n      id-token: write\n    needs: [test]\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python\n      uses: actions/setup-python@v5\n      with:\n        python-version: '3.13'\n        cache: pip\n        cache-dependency-path: setup.py\n    - name: Install dependencies\n      run: |\n        pip install setuptools wheel build\n    - name: Build\n      run: |\n        python -m build\n    - name: Publish\n      uses: pypa/gh-action-pypi-publish@release/v1\n"
  },
  {
    "path": ".github/workflows/stable-docs.yml",
    "content": "name: Update Stable Docs\n\non:\n  release:\n    types: [published]\n  push:\n    branches:\n    - main\n\npermissions:\n  contents: write\n\njobs:\n  update_stable_docs:\n    runs-on: ubuntu-latest\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v3\n      with:\n        fetch-depth: 0  # We need all commits to find docs/ changes\n    - name: Set up Git user\n      run: |\n        git config user.name \"Automated\"\n        git config user.email \"actions@users.noreply.github.com\"\n    - name: Create stable branch if it does not yet exist\n      run: |\n        if ! git ls-remote --heads origin stable | grep stable; then\n          git checkout -b stable\n          # If there are any releases, copy docs/ in from most recent\n          LATEST_RELEASE=$(git tag | sort -Vr | head -n1)\n          if [ -n \"$LATEST_RELEASE\" ]; then\n            rm -rf docs/\n            git checkout $LATEST_RELEASE -- docs/\n          fi\n          git commit -m \"Populate docs/ from $LATEST_RELEASE\" || echo \"No changes\"\n          git push -u origin stable\n        fi\n    - name: Handle Release\n      if: github.event_name == 'release' && !github.event.release.prerelease\n      run: |\n        git fetch --all\n        git checkout stable\n        git reset --hard ${GITHUB_REF#refs/tags/}\n        git push origin stable --force\n    - name: Handle Commit to Main\n      if: contains(github.event.head_commit.message, '!stable-docs')\n      run: |\n        git fetch origin\n        git checkout -b stable origin/stable\n        # Get the list of modified files in docs/ from the current commit\n        FILES=$(git diff-tree --no-commit-id --name-only -r ${{ github.sha }} -- docs/)\n        # Check if the list of files is non-empty\n        if [[ -n \"$FILES\" ]]; then\n          # Checkout those files to the stable branch to over-write with their contents\n          for FILE in $FILES; do\n            git checkout ${{ github.sha }} -- $FILE\n          done\n          git add docs/\n          git commit -m \"Doc changes from ${{ github.sha }}\"\n          git push origin stable\n        else\n          echo \"No changes to docs/ in this commit.\"\n          exit 0\n        fi\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non: [push, pull_request]\n\npermissions:\n  contents: read\n\njobs:\n  test:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [ubuntu-latest, macos-latest, windows-latest]\n        python-version: [\"3.10\", \"3.11\", \"3.12\", \"3.13\", \"3.14\"]\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python ${{ matrix.python-version }}\n      uses: actions/setup-python@v5\n      with:\n        python-version: ${{ matrix.python-version }}\n        cache: pip\n        cache-dependency-path: setup.py\n    - name: Install dependencies\n      run: |\n        pip install . --group dev\n    - name: Run tests\n      run: |\n        python -m pytest -vv\n    - name: Check if cog needs to be run\n      if: matrix.os != 'windows-latest'\n      run: |\n        cog --check \\\n          -p \"import sys, os; sys._called_from_test=True; os.environ['LLM_USER_PATH'] = '/tmp'\" \\\n          docs/**/*.md docs/*.md\n    - name: Run Black\n      if: matrix.os != 'windows-latest'\n      run: |\n        black --check .\n    - name: Run mypy\n      if: matrix.os != 'windows-latest'\n      run: |\n        mypy llm\n    - name: Run ruff\n      if: matrix.os != 'windows-latest'\n      run: |\n        ruff check .\n    - name: Check it builds\n      run: |\n        python -m build\n    - name: Run test-llm-load-plugins.sh\n      if: matrix.os != 'windows-latest'\n      run: |\n        llm install llm-cluster llm-mistral\n        ./tests/test-llm-load-plugins.sh\n    - name: Upload artifact of builds\n      if: matrix.python-version == '3.13' && matrix.os == 'ubuntu-latest'\n      uses: actions/upload-artifact@v4\n      with:\n        name: dist-${{ matrix.os }}-${{ matrix.python-version }}\n        path: dist/*\n"
  },
  {
    "path": ".gitignore",
    "content": ".venv\n__pycache__/\n*.py[cod]\n*$py.class\nvenv\n.eggs\n.pytest_cache\n*.egg-info\n.DS_Store\n.idea/\n.vscode/\nuv.lock"
  },
  {
    "path": ".readthedocs.yaml",
    "content": "version: 2\n\nbuild:\n  os: ubuntu-22.04\n  tools:\n    python: \"3.11\"\n\nsphinx:\n  configuration: docs/conf.py\n\nformats:\n   - pdf\n   - epub\n\npython:\n   install:\n   - requirements: docs/requirements.txt\n   - method: pip\n     path: .\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\nThis project uses a Python environment for development and tests.\n\n## Setting up a development environment\n\n1. Install the project with its test dependencies:\n   ```bash\n   pip install -e '.[test]'\n   ```\n2. Run the tests:\n   ```bash\n   pytest\n   ```\n\n## Building the documentation\n\nRun the following commands if you want to build the docs locally:\n\n```bash\ncd docs\npip install -r requirements.txt\nmake html\n```\n"
  },
  {
    "path": "Justfile",
    "content": "# Run tests and linters\n@default: test lint\n\n# Run pytest with supplied options\n@test *options:\n  uv run pytest {{options}}\n\n# Run linters\n@lint:\n  echo \"Linters...\"\n  echo \"  Black\"\n  uv run black . --check\n  echo \"  cog\"\n  uv run cog --check \\\n    -p \"import sys, os; sys._called_from_test=True; os.environ['LLM_USER_PATH'] = '/tmp'\" \\\n    README.md docs/*.md\n  echo \"  mypy\"\n  uv run mypy llm\n  echo \"  ruff\"\n  uv run ruff check .\n\n# Run mypy\n@mypy:\n  uv run mypy llm\n\n# Rebuild docs with cog\n@cog:\n  uv run cog -r -p \"import sys, os; sys._called_from_test=True; os.environ['LLM_USER_PATH'] = '/tmp'\" docs/**/*.md docs/*.md README.md\n\n# Serve live docs on localhost:8000\n@docs: cog\n  rm -rf docs/_build\n  cd docs && uv run make livehtml\n\n# Apply Black\n@black:\n  uv run black .\n\n# Run automatic fixes\n@fix: cog\n  uv run ruff check . --fix\n  uv run black .\n\n# Push commit if tests pass\n@push: test lint\n  git push\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "global-exclude tests/*\n"
  },
  {
    "path": "README.md",
    "content": "<!-- [[[cog\n# README.md is generated from docs/index.md using sphinx_markdown_builder\nimport tempfile\nimport subprocess\nfrom pathlib import Path\n\nreadme_markdown = ''\n\nwith tempfile.TemporaryDirectory() as tmpdir:\n    tmp_path = Path(tmpdir)\n    # Run: sphinx-build -M markdown ./docs ./tmpdir\n    subprocess.run([\n        \"sphinx-build\",\n        \"-M\", \"markdown\",\n        \"./docs\",\n        str(tmp_path)\n    ], check=True)\n    index_file = tmp_path / \"markdown\" / \"index.md\"\n    readme_markdown = index_file.read_text(encoding=\"utf-8\")\n\ncog.out(readme_markdown)\n]]] -->\n# LLM\n\n[![GitHub repo](https://img.shields.io/badge/github-repo-green)](https://github.com/simonw/llm)\n[![PyPI](https://img.shields.io/pypi/v/llm.svg)](https://pypi.org/project/llm/)\n[![Changelog](https://img.shields.io/github/v/release/simonw/llm?include_prereleases&label=changelog)](https://llm.datasette.io/en/stable/changelog.html)\n[![Tests](https://github.com/simonw/llm/workflows/Test/badge.svg)](https://github.com/simonw/llm/actions?query=workflow%3ATest)\n[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/llm/blob/main/LICENSE)\n[![Discord](https://img.shields.io/discord/823971286308356157?label=discord)](https://datasette.io/discord-llm)\n[![Homebrew](https://img.shields.io/homebrew/installs/dy/llm?color=yellow&label=homebrew&logo=homebrew)](https://formulae.brew.sh/formula/llm)\n\nA 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.\n\nWatch **[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/).\n\nWith LLM you can:\n\n- [Run prompts from the command-line](https://llm.datasette.io/en/stable/usage.html#usage-executing-prompts)\n- [Store prompts and responses in SQLite](https://llm.datasette.io/en/stable/logging.html#logging)\n- [Generate and store embeddings](https://llm.datasette.io/en/stable/embeddings/index.html#embeddings)\n- [Extract structured content from text and images](https://llm.datasette.io/en/stable/schemas.html#schemas)\n- [Grant models the ability to execute tools](https://llm.datasette.io/en/stable/tools.html#tools)\n- … and much, much more\n\n## Quick start\n\nFirst, install LLM using `pip` or Homebrew or `pipx` or `uv`:\n\n```bash\npip install llm\n```\n\nOr with Homebrew (see [warning note](https://llm.datasette.io/en/stable/setup.html#homebrew-warning)):\n\n```bash\nbrew install llm\n```\n\nOr with [pipx](https://pypa.github.io/pipx/):\n\n```bash\npipx install llm\n```\n\nOr with [uv](https://docs.astral.sh/uv/guides/tools/)\n\n```bash\nuv tool install llm\n```\n\nIf you have an [OpenAI API key](https://platform.openai.com/api-keys) key you can run this:\n\n```bash\n# Paste your OpenAI API key into this\nllm keys set openai\n\n# Run a prompt (with the default gpt-4o-mini model)\nllm \"Ten fun names for a pet pelican\"\n\n# Extract text from an image\nllm \"extract text\" -a scanned-document.jpg\n\n# Use a system prompt against a file\ncat myfile.py | llm -s \"Explain this code\"\n```\n\nRun prompts against [Gemini](https://aistudio.google.com/apikey) or [Anthropic](https://console.anthropic.com/) with their respective plugins:\n\n```bash\nllm install llm-gemini\nllm keys set gemini\n# Paste Gemini API key here\nllm -m gemini-2.0-flash 'Tell me fun facts about Mountain View'\n\nllm install llm-anthropic\nllm keys set anthropic\n# Paste Anthropic API key here\nllm -m claude-4-opus 'Impress me with wild facts about turnips'\n```\n\nYou 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/):\n\n```bash\n# Install the plugin\nllm install llm-ollama\n\n# Download and run a prompt against the Orca Mini 7B model\nollama pull llama3.2:latest\nllm -m llama3.2:latest 'What is the capital of France?'\n```\n\nTo start [an interactive chat](https://llm.datasette.io/en/stable/usage.html#usage-chat) with a model, use `llm chat`:\n\n```bash\nllm chat -m gpt-4.1\n```\n\n```default\nChatting with gpt-4.1\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 <my_fragment> [<another_fragment> ...]' to insert one or more fragments\n> Tell me a joke about a pelican\nWhy don't pelicans like to tip waiters?\n\nBecause they always have a big bill!\n```\n\nMore background on this project:\n\n- [llm, ttok and strip-tags—CLI tools for working with ChatGPT and other LLMs](https://simonwillison.net/2023/May/18/cli-tools-for-llms/)\n- [The LLM CLI tool now supports self-hosted language models via plugins](https://simonwillison.net/2023/Jul/12/llm/)\n- [LLM now provides tools for working with embeddings](https://simonwillison.net/2023/Sep/4/llm-embeddings/)\n- [Build an image search engine with llm-clip, chat with models with llm chat](https://simonwillison.net/2023/Sep/12/llm-clip-and-chat/)\n- [You can now run prompts against images, audio and video in your terminal using LLM](https://simonwillison.net/2024/Oct/29/llm-multi-modal/)\n- [Structured data extraction from unstructured content using LLM schemas](https://simonwillison.net/2025/Feb/28/llm-schemas/)\n- [Long context support in LLM 0.24 using fragments and template plugins](https://simonwillison.net/2025/Apr/7/long-context-llm/)\n\nSee also [the llm tag](https://simonwillison.net/tags/llm/) on my blog.\n\n## Contents\n\n* [Setup](https://llm.datasette.io/en/stable/setup.html)\n  * [Installation](https://llm.datasette.io/en/stable/setup.html#installation)\n  * [Upgrading to the latest version](https://llm.datasette.io/en/stable/setup.html#upgrading-to-the-latest-version)\n  * [Using uvx](https://llm.datasette.io/en/stable/setup.html#using-uvx)\n  * [A note about Homebrew and PyTorch](https://llm.datasette.io/en/stable/setup.html#a-note-about-homebrew-and-pytorch)\n  * [Installing plugins](https://llm.datasette.io/en/stable/setup.html#installing-plugins)\n  * [API key management](https://llm.datasette.io/en/stable/setup.html#api-key-management)\n    * [Saving and using stored keys](https://llm.datasette.io/en/stable/setup.html#saving-and-using-stored-keys)\n    * [Passing keys using the –key option](https://llm.datasette.io/en/stable/setup.html#passing-keys-using-the-key-option)\n    * [Keys in environment variables](https://llm.datasette.io/en/stable/setup.html#keys-in-environment-variables)\n  * [Configuration](https://llm.datasette.io/en/stable/setup.html#configuration)\n    * [Setting a custom default model](https://llm.datasette.io/en/stable/setup.html#setting-a-custom-default-model)\n    * [Setting a custom directory location](https://llm.datasette.io/en/stable/setup.html#setting-a-custom-directory-location)\n    * [Turning SQLite logging on and off](https://llm.datasette.io/en/stable/setup.html#turning-sqlite-logging-on-and-off)\n* [Usage](https://llm.datasette.io/en/stable/usage.html)\n  * [Executing a prompt](https://llm.datasette.io/en/stable/usage.html#executing-a-prompt)\n    * [Model options](https://llm.datasette.io/en/stable/usage.html#model-options)\n    * [Attachments](https://llm.datasette.io/en/stable/usage.html#attachments)\n    * [System prompts](https://llm.datasette.io/en/stable/usage.html#system-prompts)\n    * [Tools](https://llm.datasette.io/en/stable/usage.html#tools)\n    * [Extracting fenced code blocks](https://llm.datasette.io/en/stable/usage.html#extracting-fenced-code-blocks)\n    * [Schemas](https://llm.datasette.io/en/stable/usage.html#schemas)\n    * [Fragments](https://llm.datasette.io/en/stable/usage.html#fragments)\n    * [Continuing a conversation](https://llm.datasette.io/en/stable/usage.html#continuing-a-conversation)\n    * [Tips for using LLM with Bash or Zsh](https://llm.datasette.io/en/stable/usage.html#tips-for-using-llm-with-bash-or-zsh)\n    * [Completion prompts](https://llm.datasette.io/en/stable/usage.html#completion-prompts)\n  * [Starting an interactive chat](https://llm.datasette.io/en/stable/usage.html#starting-an-interactive-chat)\n  * [Listing available models](https://llm.datasette.io/en/stable/usage.html#listing-available-models)\n  * [Setting default options for models](https://llm.datasette.io/en/stable/usage.html#setting-default-options-for-models)\n* [OpenAI models](https://llm.datasette.io/en/stable/openai-models.html)\n  * [Configuration](https://llm.datasette.io/en/stable/openai-models.html#configuration)\n  * [OpenAI language models](https://llm.datasette.io/en/stable/openai-models.html#openai-language-models)\n  * [Model features](https://llm.datasette.io/en/stable/openai-models.html#model-features)\n  * [OpenAI embedding models](https://llm.datasette.io/en/stable/openai-models.html#openai-embedding-models)\n  * [OpenAI completion models](https://llm.datasette.io/en/stable/openai-models.html#openai-completion-models)\n  * [Adding more OpenAI models](https://llm.datasette.io/en/stable/openai-models.html#adding-more-openai-models)\n* [Other models](https://llm.datasette.io/en/stable/other-models.html)\n  * [Installing and using a local model](https://llm.datasette.io/en/stable/other-models.html#installing-and-using-a-local-model)\n  * [OpenAI-compatible models](https://llm.datasette.io/en/stable/other-models.html#openai-compatible-models)\n    * [Extra HTTP headers](https://llm.datasette.io/en/stable/other-models.html#extra-http-headers)\n* [Tools](https://llm.datasette.io/en/stable/tools.html)\n  * [How tools work](https://llm.datasette.io/en/stable/tools.html#how-tools-work)\n  * [Trying out tools](https://llm.datasette.io/en/stable/tools.html#trying-out-tools)\n  * [LLM’s implementation of tools](https://llm.datasette.io/en/stable/tools.html#llm-s-implementation-of-tools)\n  * [Default tools](https://llm.datasette.io/en/stable/tools.html#default-tools)\n  * [Tips for implementing tools](https://llm.datasette.io/en/stable/tools.html#tips-for-implementing-tools)\n* [Schemas](https://llm.datasette.io/en/stable/schemas.html)\n  * [Schemas tutorial](https://llm.datasette.io/en/stable/schemas.html#schemas-tutorial)\n    * [Getting started with dogs](https://llm.datasette.io/en/stable/schemas.html#getting-started-with-dogs)\n    * [Extracting people from a news articles](https://llm.datasette.io/en/stable/schemas.html#extracting-people-from-a-news-articles)\n  * [Using JSON schemas](https://llm.datasette.io/en/stable/schemas.html#using-json-schemas)\n  * [Ways to specify a schema](https://llm.datasette.io/en/stable/schemas.html#ways-to-specify-a-schema)\n  * [Concise LLM schema syntax](https://llm.datasette.io/en/stable/schemas.html#concise-llm-schema-syntax)\n  * [Saving reusable schemas in templates](https://llm.datasette.io/en/stable/schemas.html#saving-reusable-schemas-in-templates)\n  * [Browsing logged JSON objects created using schemas](https://llm.datasette.io/en/stable/schemas.html#browsing-logged-json-objects-created-using-schemas)\n* [Templates](https://llm.datasette.io/en/stable/templates.html)\n  * [Getting started with <code>–save</code>](https://llm.datasette.io/en/stable/templates.html#getting-started-with-save)\n  * [Using a template](https://llm.datasette.io/en/stable/templates.html#using-a-template)\n  * [Listing available templates](https://llm.datasette.io/en/stable/templates.html#listing-available-templates)\n  * [Templates as YAML files](https://llm.datasette.io/en/stable/templates.html#templates-as-yaml-files)\n    * [System prompts](https://llm.datasette.io/en/stable/templates.html#system-prompts)\n    * [Fragments](https://llm.datasette.io/en/stable/templates.html#fragments)\n    * [Options](https://llm.datasette.io/en/stable/templates.html#options)\n    * [Tools](https://llm.datasette.io/en/stable/templates.html#tools)\n    * [Schemas](https://llm.datasette.io/en/stable/templates.html#schemas)\n    * [Additional template variables](https://llm.datasette.io/en/stable/templates.html#additional-template-variables)\n    * [Specifying default parameters](https://llm.datasette.io/en/stable/templates.html#specifying-default-parameters)\n    * [Configuring code extraction](https://llm.datasette.io/en/stable/templates.html#configuring-code-extraction)\n    * [Setting a default model for a template](https://llm.datasette.io/en/stable/templates.html#setting-a-default-model-for-a-template)\n  * [Template loaders from plugins](https://llm.datasette.io/en/stable/templates.html#template-loaders-from-plugins)\n* [Fragments](https://llm.datasette.io/en/stable/fragments.html)\n  * [Using fragments in a prompt](https://llm.datasette.io/en/stable/fragments.html#using-fragments-in-a-prompt)\n  * [Using fragments in chat](https://llm.datasette.io/en/stable/fragments.html#using-fragments-in-chat)\n  * [Browsing fragments](https://llm.datasette.io/en/stable/fragments.html#browsing-fragments)\n  * [Setting aliases for fragments](https://llm.datasette.io/en/stable/fragments.html#setting-aliases-for-fragments)\n  * [Viewing fragments in your logs](https://llm.datasette.io/en/stable/fragments.html#viewing-fragments-in-your-logs)\n  * [Using fragments from plugins](https://llm.datasette.io/en/stable/fragments.html#using-fragments-from-plugins)\n  * [Listing available fragment prefixes](https://llm.datasette.io/en/stable/fragments.html#listing-available-fragment-prefixes)\n* [Model aliases](https://llm.datasette.io/en/stable/aliases.html)\n  * [Listing aliases](https://llm.datasette.io/en/stable/aliases.html#listing-aliases)\n  * [Adding a new alias](https://llm.datasette.io/en/stable/aliases.html#adding-a-new-alias)\n  * [Removing an alias](https://llm.datasette.io/en/stable/aliases.html#removing-an-alias)\n  * [Viewing the aliases file](https://llm.datasette.io/en/stable/aliases.html#viewing-the-aliases-file)\n* [Embeddings](https://llm.datasette.io/en/stable/embeddings/index.html)\n  * [Embedding with the CLI](https://llm.datasette.io/en/stable/embeddings/cli.html)\n    * [llm embed](https://llm.datasette.io/en/stable/embeddings/cli.html#llm-embed)\n    * [llm embed-multi](https://llm.datasette.io/en/stable/embeddings/cli.html#llm-embed-multi)\n    * [llm similar](https://llm.datasette.io/en/stable/embeddings/cli.html#llm-similar)\n    * [llm embed-models](https://llm.datasette.io/en/stable/embeddings/cli.html#llm-embed-models)\n    * [llm collections list](https://llm.datasette.io/en/stable/embeddings/cli.html#llm-collections-list)\n    * [llm collections delete](https://llm.datasette.io/en/stable/embeddings/cli.html#llm-collections-delete)\n  * [Using embeddings from Python](https://llm.datasette.io/en/stable/embeddings/python-api.html)\n    * [Working with collections](https://llm.datasette.io/en/stable/embeddings/python-api.html#working-with-collections)\n    * [Retrieving similar items](https://llm.datasette.io/en/stable/embeddings/python-api.html#retrieving-similar-items)\n    * [SQL schema](https://llm.datasette.io/en/stable/embeddings/python-api.html#sql-schema)\n  * [Writing plugins to add new embedding models](https://llm.datasette.io/en/stable/embeddings/writing-plugins.html)\n    * [Embedding binary content](https://llm.datasette.io/en/stable/embeddings/writing-plugins.html#embedding-binary-content)\n  * [Embedding storage format](https://llm.datasette.io/en/stable/embeddings/storage.html)\n* [Plugins](https://llm.datasette.io/en/stable/plugins/index.html)\n  * [Installing plugins](https://llm.datasette.io/en/stable/plugins/installing-plugins.html)\n    * [Listing installed plugins](https://llm.datasette.io/en/stable/plugins/installing-plugins.html#listing-installed-plugins)\n    * [Running with a subset of plugins](https://llm.datasette.io/en/stable/plugins/installing-plugins.html#running-with-a-subset-of-plugins)\n  * [Plugin directory](https://llm.datasette.io/en/stable/plugins/directory.html)\n    * [Local models](https://llm.datasette.io/en/stable/plugins/directory.html#local-models)\n    * [Remote APIs](https://llm.datasette.io/en/stable/plugins/directory.html#remote-apis)\n    * [Tools](https://llm.datasette.io/en/stable/plugins/directory.html#tools)\n    * [Fragments and template loaders](https://llm.datasette.io/en/stable/plugins/directory.html#fragments-and-template-loaders)\n    * [Embedding models](https://llm.datasette.io/en/stable/plugins/directory.html#embedding-models)\n    * [Extra commands](https://llm.datasette.io/en/stable/plugins/directory.html#extra-commands)\n    * [Just for fun](https://llm.datasette.io/en/stable/plugins/directory.html#just-for-fun)\n  * [Plugin hooks](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html)\n    * [register_commands(cli)](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-commands-cli)\n    * [register_models(register)](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-models-register)\n    * [register_embedding_models(register)](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-embedding-models-register)\n    * [register_tools(register)](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-tools-register)\n    * [register_template_loaders(register)](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-template-loaders-register)\n    * [register_fragment_loaders(register)](https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-fragment-loaders-register)\n  * [Developing a model plugin](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html)\n    * [The initial structure of the plugin](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#the-initial-structure-of-the-plugin)\n    * [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)\n    * [Building the Markov chain](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#building-the-markov-chain)\n    * [Executing the Markov chain](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#executing-the-markov-chain)\n    * [Adding that to the plugin](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#adding-that-to-the-plugin)\n    * [Understanding execute()](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#understanding-execute)\n    * [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)\n    * [Adding options](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#adding-options)\n    * [Distributing your plugin](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#distributing-your-plugin)\n    * [GitHub repositories](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#github-repositories)\n    * [Publishing plugins to PyPI](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#publishing-plugins-to-pypi)\n    * [Adding metadata](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#adding-metadata)\n    * [What to do if it breaks](https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#what-to-do-if-it-breaks)\n  * [Advanced model plugins](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html)\n    * [Tip: lazily load expensive dependencies](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#tip-lazily-load-expensive-dependencies)\n    * [Models that accept API keys](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#models-that-accept-api-keys)\n    * [Async models](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#async-models)\n    * [Supporting schemas](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#supporting-schemas)\n    * [Supporting tools](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#supporting-tools)\n    * [Attachments for multi-modal models](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#attachments-for-multi-modal-models)\n    * [Tracking token usage](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#tracking-token-usage)\n    * [Tracking resolved model names](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#tracking-resolved-model-names)\n    * [LLM_RAISE_ERRORS](https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#llm-raise-errors)\n  * [Utility functions for plugins](https://llm.datasette.io/en/stable/plugins/plugin-utilities.html)\n    * [llm.get_key()](https://llm.datasette.io/en/stable/plugins/plugin-utilities.html#llm-get-key)\n    * [llm.user_dir()](https://llm.datasette.io/en/stable/plugins/plugin-utilities.html#llm-user-dir)\n    * [llm.ModelError](https://llm.datasette.io/en/stable/plugins/plugin-utilities.html#llm-modelerror)\n    * [Response.fake()](https://llm.datasette.io/en/stable/plugins/plugin-utilities.html#response-fake)\n* [Python API](https://llm.datasette.io/en/stable/python-api.html)\n  * [Basic prompt execution](https://llm.datasette.io/en/stable/python-api.html#basic-prompt-execution)\n    * [System prompts](https://llm.datasette.io/en/stable/python-api.html#system-prompts)\n    * [Attachments](https://llm.datasette.io/en/stable/python-api.html#attachments)\n    * [Tools](https://llm.datasette.io/en/stable/python-api.html#tools)\n    * [Schemas](https://llm.datasette.io/en/stable/python-api.html#schemas)\n    * [Fragments](https://llm.datasette.io/en/stable/python-api.html#fragments)\n    * [Model options](https://llm.datasette.io/en/stable/python-api.html#model-options)\n    * [Passing an API key](https://llm.datasette.io/en/stable/python-api.html#passing-an-api-key)\n    * [Models from plugins](https://llm.datasette.io/en/stable/python-api.html#models-from-plugins)\n    * [Accessing the underlying JSON](https://llm.datasette.io/en/stable/python-api.html#accessing-the-underlying-json)\n    * [Token usage](https://llm.datasette.io/en/stable/python-api.html#token-usage)\n    * [Streaming responses](https://llm.datasette.io/en/stable/python-api.html#streaming-responses)\n  * [Async models](https://llm.datasette.io/en/stable/python-api.html#async-models)\n    * [Tool functions can be sync or async](https://llm.datasette.io/en/stable/python-api.html#tool-functions-can-be-sync-or-async)\n    * [Tool use for async models](https://llm.datasette.io/en/stable/python-api.html#tool-use-for-async-models)\n  * [Conversations](https://llm.datasette.io/en/stable/python-api.html#conversations)\n    * [Conversations using tools](https://llm.datasette.io/en/stable/python-api.html#conversations-using-tools)\n  * [Listing models](https://llm.datasette.io/en/stable/python-api.html#listing-models)\n  * [Running code when a response has completed](https://llm.datasette.io/en/stable/python-api.html#running-code-when-a-response-has-completed)\n  * [Other functions](https://llm.datasette.io/en/stable/python-api.html#other-functions)\n    * [set_alias(alias, model_id)](https://llm.datasette.io/en/stable/python-api.html#set-alias-alias-model-id)\n    * [remove_alias(alias)](https://llm.datasette.io/en/stable/python-api.html#remove-alias-alias)\n    * [set_default_model(alias)](https://llm.datasette.io/en/stable/python-api.html#set-default-model-alias)\n    * [get_default_model()](https://llm.datasette.io/en/stable/python-api.html#get-default-model)\n    * [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)\n* [Logging to SQLite](https://llm.datasette.io/en/stable/logging.html)\n  * [Viewing the logs](https://llm.datasette.io/en/stable/logging.html#viewing-the-logs)\n    * [-s/–short mode](https://llm.datasette.io/en/stable/logging.html#s-short-mode)\n    * [Logs for a conversation](https://llm.datasette.io/en/stable/logging.html#logs-for-a-conversation)\n    * [Searching the logs](https://llm.datasette.io/en/stable/logging.html#searching-the-logs)\n    * [Filtering past a specific ID](https://llm.datasette.io/en/stable/logging.html#filtering-past-a-specific-id)\n    * [Filtering by model](https://llm.datasette.io/en/stable/logging.html#filtering-by-model)\n    * [Filtering by prompts that used specific fragments](https://llm.datasette.io/en/stable/logging.html#filtering-by-prompts-that-used-specific-fragments)\n    * [Filtering by prompts that used specific tools](https://llm.datasette.io/en/stable/logging.html#filtering-by-prompts-that-used-specific-tools)\n    * [Browsing data collected using schemas](https://llm.datasette.io/en/stable/logging.html#browsing-data-collected-using-schemas)\n  * [Browsing logs using Datasette](https://llm.datasette.io/en/stable/logging.html#browsing-logs-using-datasette)\n  * [Backing up your database](https://llm.datasette.io/en/stable/logging.html#backing-up-your-database)\n  * [SQL schema](https://llm.datasette.io/en/stable/logging.html#sql-schema)\n* [Related tools](https://llm.datasette.io/en/stable/related-tools.html)\n  * [strip-tags](https://llm.datasette.io/en/stable/related-tools.html#strip-tags)\n  * [ttok](https://llm.datasette.io/en/stable/related-tools.html#ttok)\n  * [Symbex](https://llm.datasette.io/en/stable/related-tools.html#symbex)\n* [CLI reference](https://llm.datasette.io/en/stable/help.html)\n  * [llm  –help](https://llm.datasette.io/en/stable/help.html#llm-help)\n    * [llm prompt –help](https://llm.datasette.io/en/stable/help.html#llm-prompt-help)\n    * [llm chat –help](https://llm.datasette.io/en/stable/help.html#llm-chat-help)\n    * [llm keys –help](https://llm.datasette.io/en/stable/help.html#llm-keys-help)\n    * [llm logs –help](https://llm.datasette.io/en/stable/help.html#llm-logs-help)\n    * [llm models –help](https://llm.datasette.io/en/stable/help.html#llm-models-help)\n    * [llm templates –help](https://llm.datasette.io/en/stable/help.html#llm-templates-help)\n    * [llm schemas –help](https://llm.datasette.io/en/stable/help.html#llm-schemas-help)\n    * [llm tools –help](https://llm.datasette.io/en/stable/help.html#llm-tools-help)\n    * [llm aliases –help](https://llm.datasette.io/en/stable/help.html#llm-aliases-help)\n    * [llm fragments –help](https://llm.datasette.io/en/stable/help.html#llm-fragments-help)\n    * [llm plugins –help](https://llm.datasette.io/en/stable/help.html#llm-plugins-help)\n    * [llm install –help](https://llm.datasette.io/en/stable/help.html#llm-install-help)\n    * [llm uninstall –help](https://llm.datasette.io/en/stable/help.html#llm-uninstall-help)\n    * [llm embed –help](https://llm.datasette.io/en/stable/help.html#llm-embed-help)\n    * [llm embed-multi –help](https://llm.datasette.io/en/stable/help.html#llm-embed-multi-help)\n    * [llm similar –help](https://llm.datasette.io/en/stable/help.html#llm-similar-help)\n    * [llm embed-models –help](https://llm.datasette.io/en/stable/help.html#llm-embed-models-help)\n    * [llm collections –help](https://llm.datasette.io/en/stable/help.html#llm-collections-help)\n    * [llm openai –help](https://llm.datasette.io/en/stable/help.html#llm-openai-help)\n* [Contributing](https://llm.datasette.io/en/stable/contributing.html)\n  * [Updating recorded HTTP API interactions and associated snapshots](https://llm.datasette.io/en/stable/contributing.html#updating-recorded-http-api-interactions-and-associated-snapshots)\n  * [Debugging tricks](https://llm.datasette.io/en/stable/contributing.html#debugging-tricks)\n  * [Documentation](https://llm.datasette.io/en/stable/contributing.html#documentation)\n  * [Release process](https://llm.datasette.io/en/stable/contributing.html#release-process)\n\n* [Changelog](https://llm.datasette.io/en/stable/changelog.html)\n<!-- [[[end]]] -->\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "_build\n"
  },
  {
    "path": "docs/Makefile",
    "content": "# Minimal makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line.\nSPHINXOPTS    =\nSPHINXBUILD   = sphinx-build\nSPHINXPROJ    = sqlite-utils\nSOURCEDIR     = .\nBUILDDIR      = _build\n\n# Put it first so that \"make\" without argument is like \"make help\".\nhelp:\n\t@$(SPHINXBUILD) -M help \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(O)\n\n.PHONY: help Makefile\n\n# Catch-all target: route all unknown targets to Sphinx using the new\n# \"make mode\" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).\n%: Makefile\n\t@$(SPHINXBUILD) -M $@ \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(O)\n\nlivehtml:\n\tsphinx-autobuild -b html \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(0)\n"
  },
  {
    "path": "docs/_templates/base.html",
    "content": "{%- extends \"!base.html\" %}\n\n{%- block htmltitle -%}\n{% if not docstitle %}\n  <title>{{ title|striptags|e }}</title>\n{% elif pagename == master_doc %}\n  <title>LLM: A CLI utility and Python library for interacting with Large Language Models</title>\n{% else %}\n  <title>{{ title|striptags|e }} - {{ docstitle|striptags|e }}</title>\n{% endif %}\n{%- endblock -%}\n\n{% block site_meta %}\n{{ super() }}\n<script defer data-domain=\"llm.datasette.io\" src=\"https://plausible.io/js/plausible.js\"></script>\n{% endblock %}\n"
  },
  {
    "path": "docs/aliases.md",
    "content": "(aliases)=\n# Model aliases\n\nLLM supports model aliases, which allow you to refer to a model by a short name instead of its full ID.\n\n## Listing aliases\n\nTo list current aliases, run this:\n\n```bash\nllm aliases\n```\nExample output:\n\n<!-- [[[cog\nfrom click.testing import CliRunner\nfrom llm.cli import cli\nresult = CliRunner().invoke(cli, [\"aliases\", \"list\"])\ncog.out(\"```\\n{}```\".format(result.output))\n]]] -->\n```\n4o                  : gpt-4o\nchatgpt-4o          : chatgpt-4o-latest\n4o-mini             : gpt-4o-mini\n4.1                 : gpt-4.1\n4.1-mini            : gpt-4.1-mini\n4.1-nano            : gpt-4.1-nano\n3.5                 : gpt-3.5-turbo\nchatgpt             : gpt-3.5-turbo\nchatgpt-16k         : gpt-3.5-turbo-16k\n3.5-16k             : gpt-3.5-turbo-16k\n4                   : gpt-4\ngpt4                : gpt-4\n4-32k               : gpt-4-32k\ngpt-4-turbo-preview : gpt-4-turbo\n4-turbo             : gpt-4-turbo\n4t                  : gpt-4-turbo\ngpt-4.5             : gpt-4.5-preview\n3.5-instruct        : gpt-3.5-turbo-instruct\nchatgpt-instruct    : gpt-3.5-turbo-instruct\nada                 : text-embedding-ada-002 (embedding)\nada-002             : text-embedding-ada-002 (embedding)\n3-small             : text-embedding-3-small (embedding)\n3-large             : text-embedding-3-large (embedding)\n3-small-512         : text-embedding-3-small-512 (embedding)\n3-large-256         : text-embedding-3-large-256 (embedding)\n3-large-1024        : text-embedding-3-large-1024 (embedding)\n```\n<!-- [[[end]]] -->\n\nAdd `--json` to get that list back as JSON:\n\n```bash\nllm aliases list --json\n```\nExample output:\n```json\n{\n    \"3.5\": \"gpt-3.5-turbo\",\n    \"chatgpt\": \"gpt-3.5-turbo\",\n    \"4\": \"gpt-4\",\n    \"gpt4\": \"gpt-4\",\n    \"ada\": \"ada-002\"\n}\n```\n\n## Adding a new alias\n\nThe `llm aliases set <alias> <model-id>` command can be used to add a new alias:\n\n```bash\nllm aliases set mini gpt-4o-mini\n```\nYou can also pass one or more `-q search` options to set an alias on the first model matching those search terms:\n```bash\nllm aliases set mini -q 4o -q mini\n```\nNow you can run the `gpt-4o-mini` model using the `mini` alias like this:\n```bash\nllm -m mini 'An epic Greek-style saga about a cheesecake that builds a SQL database from scratch'\n```\nAliases can be set for both regular models and {ref}`embedding models <embeddings>` using the same command. To set an alias of `oai` for the OpenAI `ada-002` embedding model use this:\n```bash\nllm aliases set oai ada-002\n```\nNow you can embed a string using that model like so:\n```bash\nllm embed -c 'hello world' -m oai\n```\nOutput:\n```\n[-0.014945968054234982, 0.0014304015785455704, ...]\n```\n\n## Removing an alias\n\nThe `llm aliases remove <alias>` command will remove the specified alias:\n\n```bash\nllm aliases remove mini\n```\n\n## Viewing the aliases file\n\nAliases are stored in an `aliases.json` file in the LLM configuration directory.\n\nTo see the path to that file, run this:\n\n```bash\nllm aliases path\n```\nTo view the content of that file, run this:\n\n```bash\ncat \"$(llm aliases path)\"\n```"
  },
  {
    "path": "docs/changelog.md",
    "content": "# Changelog\n\n(v0_29)=\n## 0.29 (2025-03-17)\n\n- The `-t/--template` option now works correctly with the `-x/--extract` and `--xl/--extract-last` flags.\n- `llm logs` now shows any additional model options in the Markdown output. [#1322](https://github.com/simonw/llm/issues/1322)\n- New OpenAI models: `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`. [#1376](https://github.com/simonw/llm/issues/1376)\n\n(v0_28)=\n## 0.28 (2025-12-12)\n\n- 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)\n- LLM now requires Python 3.10 or higher. Python 3.14 is now covered by the tests.\n- 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)\n- 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)\n- Fixed some file descriptor leak warnings. Thanks, [Eric Bloch](https://github.com/eedeebee). [#1313](https://github.com/simonw/llm/issues/1313)\n- Fixed a deprecation warning for `asyncio.iscoroutinefunction`.\n- 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)\n- The project now uses `uv` and dependency groups for development. See the updated {ref}`contributing documentation <contributing>`. [#1318](https://github.com/simonw/llm/issues/1318)\n\n(v0_27_1)=\n## 0.27.1 (2025-08-11)\n\n- `llm chat -t template` now correctly loads any tools that are included in that template. [#1239](https://github.com/simonw/llm/issues/1239)\n- 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)\n- Fixed a bug where running `llm chat -t template` could cause prompts to be duplicated. [#1240](https://github.com/simonw/llm/issues/1240)\n- Less confusing error message if a requested toolbox class is unavailable. [#1238](https://github.com/simonw/llm/issues/1238)\n\n(v0_27)=\n## 0.27 (2025-08-11)\n\nThis 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 <prompt-templates>` to bundle pre-configured tools.\n\n### New features\n\n- New models: `gpt-5`, `gpt-5-mini` and `gpt-5-nano`. [#1229](https://github.com/simonw/llm/issues/1229)\n- LLM {ref}`templates <prompt-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 <prompt-templates-tools>`. [#1009](https://github.com/simonw/llm/issues/1009)\n- Tools {ref}`can now return attachments <python-api-tools-attachments>`, for models that support features such as image input. [#1014](https://github.com/simonw/llm/issues/1014)\n- New methods on the `Toolbox` class: `.add_tool()`, `.prepare()` and `.prepare_async()`, described in {ref}`Dynamic toolboxes <python-api-tools-dynamic>`. [#1111](https://github.com/simonw/llm/issues/1111)\n- 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 <python-api-tools-debug-hooks>` for details. [#1088](https://github.com/simonw/llm/issues/1088)\n- 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)\n- 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)\n- 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)\n\n### Bug fixes and documentation\n\n- 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)\n- 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)\n- Show visible stack trace for `llm templates show invalid-template-name`. [#1053](https://github.com/simonw/llm/issues/1053)\n- Handle invalid tool names more gracefully in `llm chat`. [#1104](https://github.com/simonw/llm/issues/1104)\n- Add a {ref}`Tool plugins <plugin-directory-tools>` section to the plugin directory. [#1110](https://github.com/simonw/llm/issues/1110)\n- Error on `register(Klass)` if the passed class is not a subclass of `Toolbox`. [#1114](https://github.com/simonw/llm/issues/1114)\n- Add `-h` for `--help` for all `llm` CLI commands. [#1134](https://github.com/simonw/llm/issues/1134)\n- Add missing `dataclasses` to advanced model plugins docs. [#1137](https://github.com/simonw/llm/issues/1137)\n- 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)\n- All extra OpenAI model keys in `extra-openai-models.yaml` are {ref}`now documented <openai-compatible-models>`. [#1228](https://github.com/simonw/llm/issues/1228)\n\n(v0_26)=\n## 0.26 (2025-05-27)\n\n**Tool support** is finally here! This release adds support exposing {ref}`tools <tools>` to LLMs, previously described in the release notes for {ref}`0.26a0 <v0_26_a0>` and {ref}`0.26a1 <v0_26_a1>`.\n\nRead **[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.\n\nAlso in this release:\n\n- Two new {ref}`default tools <tools-default>`: `llm_version()` and `llm_time()`. [#1096](https://github.com/simonw/llm/issues/1096), [#1103](https://github.com/simonw/llm/issues/1103)\n- Documentation on {ref}`how to add tool supports to a model plugin <advanced-model-plugins-tools>`. [#1000](https://github.com/simonw/llm/issues/1000)\n- Added a {ref}`prominent warning <tools-warning>` about the risk of prompt injection when using tools. [#1097](https://github.com/simonw/llm/issues/1097)\n- 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)\n- New `tool_instances` table records details of Toolbox instances created while executing a prompt. [#1089](https://github.com/simonw/llm/issues/1089)\n- `llm.get_key()` is now a {ref}`documented utility function <plugin-utilities-get-key>`. [#1094](https://github.com/simonw/llm/issues/1094)\n\n(v0_26_a1)=\n## 0.26a1 (2025-05-25)\n\nHopefully the last alpha before a stable release that includes tool support.\n\n### Features\n\n*   **Plugin-provided tools can now be grouped into \"Toolboxes\".**\n    *   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))\n*   **Tool support for `llm chat`.**\n    *   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))\n*   **Tools can now execute asynchronously.**\n    *   Models that implement `AsyncModel` can now run tools, including tool functions defined as `async def`. ([#1063](https://github.com/simonw/llm/issues/1063))\n*   **`llm chat` now supports adding fragments during a session.**\n    *   Use the new `!fragment <id>` 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))\n*   **Filter `llm logs` by tools.**\n    *   New `--tool <name>` option to filter logs to show only responses that involved a specific tool (e.g., `--tool simple_eval`).\n    *   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))\n*   **`llm schemas list` can output JSON.**\n    *   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))\n*   **Filter `llm similar` results by ID prefix.**\n    *   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))\n*   **Control chained tool execution limit.**\n    *   New `--chain-limit <N>` (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))\n*   **`llm plugins --hook <NAME>` option.**\n    *   Filter the list of installed plugins to only show those that implement a specific plugin hook. ([#1047](https://github.com/simonw/llm/issues/1047))\n* `llm tools list` now shows toolboxes and their methods. ([#1013](https://github.com/simonw/llm/issues/1013))\n* `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))\n* The `--tools-debug` option now pretty-prints JSON tool results for improved readability. ([#1083](https://github.com/simonw/llm/issues/1083))\n* New `LLM_TOOLS_DEBUG` environment variable to permanently enable `--tools-debug`. ([#1045](https://github.com/simonw/llm/issues/1045))\n* `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))\n* New `--pre` option for `llm install` to allow installing pre-release packages. ([#1060](https://github.com/simonw/llm/issues/1060))\n* OpenAI models (`gpt-4o`, `gpt-4o-mini`) now explicitly declare support for tools and vision. ([#1037](https://github.com/simonw/llm/issues/1037))\n* 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))\n\n### Bug fixes\n\n*   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))\n*   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))\n\n\n(v0_26_a0)=\n## 0.26a0 (2025-05-13)\n\nThis is the first alpha to introduce {ref}`support for tools<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.\n\nTools are supported by {ref}`the command-line interface <usage-tools>`:\n\n```bash\nllm --functions '\ndef multiply(x: int, y: int) -> int:\n    \"\"\"Multiply two numbers.\"\"\"\n    return x * y\n' 'what is 34234 * 213345'\n```\nAnd in {ref}`the Python API <python-api-tools>`, using a new `model.chain()` method for executing multiple prompts in a sequence:\n```python\nimport llm\n\ndef multiply(x: int, y: int) -> int:\n    \"\"\"Multiply two numbers.\"\"\"\n    return x * y\n\nmodel = llm.get_model(\"gpt-4.1-mini\")\nresponse = model.chain(\n    \"What is 34234 * 213345?\",\n    tools=[multiply]\n)\nprint(response.text())\n```\nNew tools can also be defined using the {ref}`register_tools() plugin hook <plugin-hooks-register-tools>`. They can then be called by name from the command-line like this:\n```bash\nllm -T multiply 'What is 34234 * 213345?'\n```\nTool support is currently under **active development**. Consult [this milestone](https://github.com/simonw/llm/milestone/12) for the latest status.\n\n(v0_25)=\n## 0.25 (2025-05-04)\n\n- 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)\n- 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).\n- 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)\n- New command: `llm fragments loaders`, to list all currently available fragment loader prefixes provided by plugins. [#941](https://github.com/simonw/llm/issues/941)\n- `llm fragments` command now shows fragments ordered by the date they were first used. [#973](https://github.com/simonw/llm/issues/973)\n- `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)\n- Allow `-t` and `--system` to be used at the same time. [#916](https://github.com/simonw/llm/issues/916)\n- 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)\n- Improved documentation for {ref}`extra-openai-models.yaml <openai-compatible-models>`. 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)\n- `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)\n\n(v0_25a0)=\n## 0.25a0 (2025-04-10)\n\n- `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)\n- 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)\n- `$` 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)\n- LLM now uses `pyproject.toml` instead of `setup.py`. [#908](https://github.com/simonw/llm/issues/908)\n\n(v0_24_2)=\n## 0.24.2 (2025-04-08)\n\n- Fixed a bug on Windows with the new `llm -t path/to/file.yaml` feature. [#901](https://github.com/simonw/llm/issues/901)\n\n(v0_24_1)=\n## 0.24.1 (2025-04-08)\n\n- 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)\n- `llm logs backup /tmp/backup.db` command for {ref}`backing up your <logging-backup>` `logs.db` database. [#879](https://github.com/simonw/llm/issues/879)\n\n(v0_24)=\n## 0.24 (2025-04-07)\n\nSupport 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.\n\nThe new [llm-docs](https://github.com/simonw/llm-docs) plugin demonstrates these new features. Install it like this:\n\n```bash\nllm install llm-docs\n```\nNow you can ask questions of the LLM documentation like this:\n\n```bash\nllm -f docs: 'How do I save a new template?'\n```\nThe `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.\n\nTwo 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).\n\n`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:\n\n```bash\nllm install llm-templates-github\nllm -t gh:simonw/pelican-svg -m o3-mini\n```\nThis 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.\n\nTo share your own templates, create a repository on GitHub under your user account called `llm-templates` and start saving `.yaml` files to it.\n\n[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):\n\n```bash\nllm install llm-templates-fabric\ncurl https://simonwillison.net/2025/Apr/6/only-miffy/ | \\\n  llm -t f:extract_main_idea\n```\n\nMajor new features:\n\n- New {ref}`fragments feature <fragments>`. 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)\n- The `llm logs` file now accepts `-f` fragment references too, and will show just logged prompts that used those fragments.\n- {ref}`register_template_loaders() plugin hook <plugin-hooks-register-template-loaders>` allowing plugins to register new `prefix:value` custom template loaders. [#809](https://github.com/simonw/llm/issues/809)\n- {ref}`register_fragment_loaders() plugin hook <plugin-hooks-register-fragment-loaders>` allowing plugins to register new `prefix:value` custom fragment loaders. [#886](https://github.com/simonw/llm/issues/886)\n- {ref}`llm fragments <fragments-browsing>` family of commands for browsing fragments that have been previously logged to the database.\n- 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.\n\nImprovements to templates:\n\n- `llm -t $URL` option can now take a URL to a YAML template. [#856](https://github.com/simonw/llm/issues/856)\n- Templates can now store default model options. [#845](https://github.com/simonw/llm/issues/845)\n- 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)\n- `llm templates` command no longer crashes if one of the listed template files contains invalid YAML. [#880](https://github.com/simonw/llm/issues/880)\n- Attachments can now be stored in templates. [#826](https://github.com/simonw/llm/issues/826)\n\nOther changes:\n\n- New {ref}`llm models options <usage-executing-default-options>` family of commands for setting default options for particular models. [#829](https://github.com/simonw/llm/issues/829)\n- `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)\n- `llm logs -e/--expand` option for expanding fragments. [#881](https://github.com/simonw/llm/issues/881)\n- `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)\n- `llm similar -p/--plain` option providing more human-readable output than the default JSON. [#853](https://github.com/simonw/llm/issues/853)\n- `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)\n- 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)\n- 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)\n- `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)\n- OpenAI models now accept PDF attachments. [#834](https://github.com/simonw/llm/issues/834)\n- `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)\n- {ref}`OpenAI compatible models <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)\n\n\n(v0_24a1)=\n## 0.24a1 (2025-04-06)\n\n- New Fragments feature. [#617](https://github.com/simonw/llm/issues/617)\n- `register_fragment_loaders()` plugin hook. [#809](https://github.com/simonw/llm/issues/886)\n\n(v0_24a0)=\n## 0.24a0 (2025-02-28)\n\n- Alpha release with experimental `register_template_loaders()` plugin hook. [#809](https://github.com/simonw/llm/issues/809)\n\n(v0_23)=\n## 0.23 (2025-02-28)\n\nSupport 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)\n\n- 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 <schemas>` has more details and a tutorial.\n- Schemas can also be defined using a {ref}`concise schema specification <schemas-dsl>`, for example `llm prompt --schema 'name, bio, age int'`. [#790](https://github.com/simonw/llm/issues/790)\n- Schemas can also be specified by passing a filename and through {ref}`several other methods <schemas-specify>`. [#780](https://github.com/simonw/llm/issues/780)\n- New {ref}`llm schemas family of commands <help-schemas>`: `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)\n- Schemas can now be saved to templates using `llm --schema X --save template-name` or through modifying the {ref}`template YAML <prompt-templates-yaml>`. [#778](https://github.com/simonw/llm/issues/778)\n- The {ref}`llm logs <logging>` 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)\n- New `llm logs --id-gt X` and `--id-gte X` options. [#801](https://github.com/simonw/llm/issues/801)\n- New `llm models --schemas` option for listing models that support schemas. [#797](https://github.com/simonw/llm/issues/797)\n- `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 <python-api-schemas>`.\n- The default OpenAI plugin now enables schemas across all supported models. Run `llm models --schemas` for a list of these.\n- 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 <advanced-model-plugins-schemas>`.\n\nOther smaller changes:\n\n- [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)\n- 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)\n- `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)\n- LLM now depends on Pydantic v2 or higher. Pydantic v1 is no longer supported. [#520](https://github.com/simonw/llm/issues/520)\n\n\n(v0_22)=\n## 0.22 (2025-02-16)\n\nSee also [LLM 0.22, the annotated release notes](https://simonwillison.net/2025/Feb/17/llm/).\n\n- 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 <python-api-models-api-keys>`. Plugin developers should consult the new documentation on writing {ref}`Models that accept API keys <advanced-model-plugins-api-keys>`. [#744](https://github.com/simonw/llm/issues/744)\n- 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)\n- 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)\n- 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)\n- 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)\n- The `response.json()` and `response.usage()` methods are {ref}`now documented <python-api-underlying-json>`.\n- 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)\n- 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.\n- The `llm-claude-3` plugin has been renamed to [llm-anthropic](https://github.com/simonw/llm-anthropic).\n\n(v0_21)=\n## 0.21 (2025-01-31)\n\n- New model: `o3-mini`. [#728](https://github.com/simonw/llm/issues/728)\n- The `o3-mini` and `o1` models now support a `reasoning_effort` option which can be set to `low`, `medium` or `high`.\n- `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)\n\n(v0_20)=\n## 0.20 (2025-01-22)\n\n- New model, `o1`. This model does not yet support streaming. [#676](https://github.com/simonw/llm/issues/676)\n- `o1-preview` and `o1-mini` models now support streaming.\n- New models, `gpt-4o-audio-preview` and `gpt-4o-mini-audio-preview`. [#677](https://github.com/simonw/llm/issues/677)\n- `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)\n  - 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`.\n  - New `llm logs -x/--extract` option extracts the first fenced code block from matching logged responses.\n- New `llm models -q 'search'` option returning models that case-insensitively match the search query. [#700](https://github.com/simonw/llm/issues/700)\n- 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)\n- `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)\n- {ref}`Plugin directory <plugin-directory>` now includes `llm-venice`, `llm-bedrock`, `llm-deepseek` and `llm-cmd-comp`.\n- 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)\n- 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)\n\n(v0_19_1)=\n## 0.19.1 (2024-12-05)\n\n- 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)\n\n(v0_19)=\n## 0.19 (2024-12-01)\n\n- 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 <advanced-model-plugins-usage>`. [#610](https://github.com/simonw/llm/issues/610)\n- `llm prompt` now takes a `-u/--usage` flag to display token usage at the end of the response.\n- `llm logs -u/--usage` shows token usage information for logged responses.\n- `llm prompt ... --async` responses are now logged to the database. [#641](https://github.com/simonw/llm/issues/641)\n- `llm.get_models()` and `llm.get_async_models()` functions, {ref}`documented here <python-api-listing-models>`. [#640](https://github.com/simonw/llm/issues/640)\n- `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)\n- `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 <python-api-response-on-done>`. [#653](https://github.com/simonw/llm/issues/653)\n- Fix for bug running `llm chat` on Windows 11. Thanks, [Sukhbinder Singh](https://github.com/sukhbinder). [#495](https://github.com/simonw/llm/issues/495)\n\n(v0_19a2)=\n## 0.19a2 (2024-11-20)\n\n- `llm.get_models()` and `llm.get_async_models()` functions, {ref}`documented here <python-api-listing-models>`. [#640](https://github.com/simonw/llm/issues/640)\n\n(v0_19a1)=\n## 0.19a1 (2024-11-19)\n\n- `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)\n\n(v0_19a0)=\n## 0.19a0 (2024-11-19)\n\n- 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 <advanced-model-plugins-usage>`. [#610](https://github.com/simonw/llm/issues/610)\n- `llm prompt` now takes a `-u/--usage` flag to display token usage at the end of the response.\n- `llm logs -u/--usage` shows token usage information for logged responses.\n- `llm prompt ... --async` responses are now logged to the database. [#641](https://github.com/simonw/llm/issues/641)\n\n(v0_18)=\n## 0.18 (2024-11-17)\n\n- 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<python-api-async>` and {ref}`implementing async models in plugins <advanced-model-plugins-async>`. [#507](https://github.com/simonw/llm/issues/507)\n- OpenAI models all now include async models, so function calls such as `llm.get_async_model(\"gpt-4o-mini\")` will return an async model.\n- `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)\n- Attachments can now be sent without requiring a prompt. [#611](https://github.com/simonw/llm/issues/611)\n- `llm models --options` now includes information on whether a model supports attachments. [#612](https://github.com/simonw/llm/issues/612)\n- `llm models --async` shows available async models.\n- 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)\n- Fixed bug where OpenAI usage data was incorrectly serialized to JSON. [#614](https://github.com/simonw/llm/issues/614)\n- Standardized on `audio/wav` MIME type for audio attachments rather than `audio/wave`. [#603](https://github.com/simonw/llm/issues/603)\n\n(v0_18a1)=\n## 0.18a1 (2024-11-14)\n\n- Fixed bug where conversations did not work for async OpenAI models. [#632](https://github.com/simonw/llm/issues/632)\n- `__repr__` methods for `Response` and `AsyncResponse`.\n\n(v0_18a0)=\n## 0.18a0 (2024-11-13)\n\nAlpha support for **async models**. [#507](https://github.com/simonw/llm/issues/507)\n\nMultiple [smaller changes](https://github.com/simonw/llm/compare/0.17.1...0.18a0).\n\n(v0_17)=\n## 0.17 (2024-10-29)\n\nSupport for **attachments**, allowing multi-modal models to accept images, audio, video and other formats. [#578](https://github.com/simonw/llm/issues/578)\n\nThe default OpenAI `gpt-4o` and `gpt-4o-mini` models can both now be prompted with JPEG, GIF, PNG and WEBP images.\n\nAttachments {ref}`in the CLI <usage-attachments>` can be URLs:\n\n```bash\nllm -m gpt-4o \"describe this image\" \\\n  -a https://static.simonwillison.net/static/2024/pelicans.jpg\n```\nOr file paths:\n```bash\nllm -m gpt-4o-mini \"extract text\" -a image1.jpg -a image2.jpg\n```\nOr binary data, which may need to use `--attachment-type` to specify the MIME type:\n```bash\ncat image | llm -m gpt-4o-mini \"extract text\" --attachment-type - image/jpeg\n```\n\nAttachments are also available {ref}`in the Python API <python-api-attachments>`:\n\n```python\nmodel = llm.get_model(\"gpt-4o-mini\")\nresponse = model.prompt(\n    \"Describe these images\",\n    attachments=[\n        llm.Attachment(path=\"pelican.jpg\"),\n        llm.Attachment(url=\"https://static.simonwillison.net/static/2024/pelicans.jpg\"),\n    ]\n)\n```\nPlugins that provide alternative models can support attachments, see {ref}`advanced-model-plugins-attachments` for details.\n\nThe 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.\n\nAlso 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)\n\n(v0_17a0)=\n## 0.17a0 (2024-10-28)\n\nAlpha support for **attachments**. [#578](https://github.com/simonw/llm/issues/578)\n\n(v0_16)=\n## 0.16 (2024-09-12)\n\n- 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:\n    ```python\n    import llm\n    print(llm.get_model(\"gpt-4o-mini\").prompt(\"hi\"))\n    ```\n- 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)\n- 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)\n\n(v0_15)=\n## 0.15 (2024-07-18)\n\n- 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)\n- `gpt-4o-mini` is now the default model if you do not {ref}`specify your own default <setup-default-model>`, replacing GPT-3.5 Turbo. GPT-4o mini is both cheaper and better than GPT-3.5 Turbo.\n- 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)\n\n(v0_14)=\n## 0.14 (2024-05-13)\n\n- 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)\n- 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)\n- 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)\n- Nine new {ref}`plugins <plugin-directory>` since version 0.13:\n  - **[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.\n  - **[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.\n  - **[llm-reka](https://github.com/simonw/llm-reka)** supports the [Reka](https://www.reka.ai/) family of models via their API.\n  - **[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`.\n  - **[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).\n  - **[llm-fireworks](https://github.com/simonw/llm-fireworks)** supporting models hosted by [Fireworks AI](https://fireworks.ai/).\n  - **[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.\n  - **[llm-embed-onnx](https://github.com/simonw/llm-embed-onnx)** provides seven embedding models that can be executed using the ONNX model framework.\n  - **[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 `<enter>` to execute or `ctrl+c` to cancel, see [this post for details](https://simonwillison.net/2024/Mar/26/llm-cmd/).\n\n(v0_13_1)=\n## 0.13.1 (2024-01-26)\n\n- Fix for `No module named 'readline'` error on Windows. [#407](https://github.com/simonw/llm/issues/407)\n\n(v0_13)=\n## 0.13 (2024-01-26)\n\nSee also [LLM 0.13: The annotated release notes](https://simonwillison.net/2024/Jan/26/llm/).\n\n- Added support for new OpenAI embedding models: `3-small` and `3-large` and three variants of those with different dimension sizes, \n`3-small-512`, `3-large-256` and `3-large-1024`. See {ref}`OpenAI embedding models <openai-models-embedding>` for details. [#394](https://github.com/simonw/llm/issues/394)\n- 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)\n- New OpenAI model aliases `gpt-4-1106-preview` and `gpt-4-0125-preview`.\n- 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)\n- New {ref}`plugins <plugin-directory>` 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).\n- The `keys.json` file for storing API keys is now created with `600` file permissions. [#351](https://github.com/simonw/llm/issues/351)\n- Documented {ref}`a pattern <homebrew-warning>` 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)\n- 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)\n- Arrow keys now work inside the `llm chat` command. [#376](https://github.com/simonw/llm/issues/376)\n- `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)\n- Dropped support for Python 3.7.\n\n(v0_12)=\n## 0.12 (2023-11-06)\n\n- 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)\n- 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)\n\n(v0_11_2)=\n## 0.11.2 (2023-11-06)\n\n- Pin to version of OpenAI Python library prior to 1.0 to avoid breaking. [#327](https://github.com/simonw/llm/issues/327)\n\n(v0_11_1)=\n## 0.11.1 (2023-10-31)\n\n- Fixed a bug where `llm embed -c \"text\"` did not correctly pick up the configured {ref}`default embedding model <embeddings-cli-embed-models-default>`. [#317](https://github.com/simonw/llm/issues/317)\n- 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/)).\n- [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)\n\n(v0_11)=\n## 0.11 (2023-09-18)\n\nLLM 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)\n\n```bash\nllm -m gpt-3.5-turbo-instruct 'Reasons to tame a wild beaver:'\n```\nOpenAI 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.\n\n```bash\nllm -m gpt-3.5-turbo-instruct 'Say hello succinctly' -o logprobs 3\n```\nYou can then view the `logprobs` that were recorded in the SQLite logs database like this:\n```bash\nsqlite-utils \"$(llm logs path)\" \\\n  'select * from responses order by id desc limit 1' | \\\n  jq '.[0].response_json' -r | jq\n```\nTruncated output looks like this:\n```\n  [\n    {\n      \"text\": \"Hi\",\n      \"top_logprobs\": [\n        {\n          \"Hi\": -0.13706253,\n          \"Hello\": -2.3714375,\n          \"Hey\": -3.3714373\n        }\n      ]\n    },\n    {\n      \"text\": \" there\",\n      \"top_logprobs\": [\n        {\n          \" there\": -0.96057636,\n          \"!\\\"\": -0.5855763,\n          \".\\\"\": -3.2574513\n        }\n      ]\n    }\n  ]\n```\nAlso in this release:\n\n- The `llm.user_dir()` function, used by plugins, now ensures the directory exists before returning it. [#275](https://github.com/simonw/llm/issues/275)\n- 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)\n- 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)\n- The `collection.embed_multi()` method also now accepts an optional `batch_size=int` argument.\n- 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)\n\n(v0_10)=\n## 0.10 (2023-09-12)\n\nThe two major features in this release are the **{ref}`llm chat command <usage-chat>`** and support for **embedding binary data**.\n\nSee [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.\n\n### llm chat\n\nThe 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 <plugins>`, including locally installed models such as Llama 2. [#231](https://github.com/simonw/llm/issues/231)\n\nThis offers a big performance boost for local models, since they don't need to be freshly loaded into memory for each prompt.\n\nHere's an example chat with Llama 2 13B, provided by the [llm-mlc](https://github.com/simonw/llm-mlc) plugin.\n\n```bash\nllm chat -m mlc-chat-Llama-2-13b-chat-hf-q4f16_1\n```\nThis starts a chat session:\n```\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.\n> Who are you?\nHello! I'm just an AI, here to assist you with any questions you may have.\nMy name is LLaMA, and I'm a large language model trained to provide helpful\nand informative responses to a wide range of questions and topics. I'm here\nto help you find the information you need, while ensuring a safe and\nrespectful conversation. Please feel free to ask me anything, and I'll do my\nbest to provide a helpful and accurate response.\n> Tell me a joke about otters\nSure, here's a joke about otters:\n\nWhy did the otter go to the party?\n\nBecause he heard it was a \"whale\" of a time!\n\n(Get it? Whale, like a big sea mammal, but also a \"wild\" or \"fun\" time.\nOtters are known for their playful and social nature, so it's a lighthearted\nand silly joke.)\n\nI hope that brought a smile to your face! Do you have any other questions or\ntopics you'd like to discuss?\n> exit\n```\nChat sessions are {ref}`logged to SQLite <logging>` - use `llm logs` to view them. They can accept system prompts, templates and model options - consult {ref}`the chat documentation <usage-chat>` for details.\n\n### Binary embedding support\n\nLLM's {ref}`embeddings feature <embeddings>` has been expanded to provide support for embedding binary data, in addition to text. [#254](https://github.com/simonw/llm/pull/254)\n\nThis enables models like [CLIP](https://openai.com/research/clip), supported by the new **[llm-clip](https://github.com/simonw/llm-clip)** plugin.\n\nCLIP 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.\n\nTo create embeddings for every JPEG in a directory stored in a `photos` collection, run:\n\n```bash\nllm install llm-clip\nllm embed-multi photos --files photos/ '*.jpg' --binary -m clip\n```\nNow you can search for photos of raccoons using:\n```\nllm similar photos -c 'raccoon'\n```\nThis spits out a list of images, ranked by how similar they are to the string \"raccoon\":\n```\n{\"id\": \"IMG_4801.jpeg\", \"score\": 0.28125139257127457, \"content\": null, \"metadata\": null}\n{\"id\": \"IMG_4656.jpeg\", \"score\": 0.26626441704164294, \"content\": null, \"metadata\": null}\n{\"id\": \"IMG_2944.jpeg\", \"score\": 0.2647445926996852, \"content\": null, \"metadata\": null}\n...\n```\n\n### Also in this release\n\n- The {ref}`LLM_LOAD_PLUGINS environment variable <llm-load-plugins>` can be used to control which plugins are loaded when `llm` starts running. [#256](https://github.com/simonw/llm/issues/256)\n- The `llm plugins --all` option includes builtin plugins in the list of plugins. [#259](https://github.com/simonw/llm/issues/259)\n- The `llm embed-db` family of commands has been renamed to `llm collections`. [#229](https://github.com/simonw/llm/issues/229)\n- `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)\n\n(v0_10_a1)=\n## 0.10a1 (2023-09-11)\n\n- Support for embedding binary data. [#254](https://github.com/simonw/llm/pull/254)\n- `llm chat` now works for models with API keys. [#247](https://github.com/simonw/llm/issues/247)\n- `llm chat -o` for passing options to a model. [#244](https://github.com/simonw/llm/issues/244)\n- `llm chat --no-stream` option. [#248](https://github.com/simonw/llm/issues/248)\n- `LLM_LOAD_PLUGINS` environment variable. [#256](https://github.com/simonw/llm/issues/256)\n- `llm plugins --all` option for including builtin plugins. [#259](https://github.com/simonw/llm/issues/259)\n- `llm embed-db` has been renamed to `llm collections`. [#229](https://github.com/simonw/llm/issues/229)\n- 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)\n\n(v0_10_a0)=\n## 0.10a0 (2023-09-04)\n\n- New {ref}`llm chat <usage-chat>` command for starting an interactive terminal chat with a model. [#231](https://github.com/simonw/llm/issues/231)\n- `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)\n\n(v0_9)=\n## 0.9 (2023-09-03)\n\nThe 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.\n\n{ref}`Embedding models <embeddings>` 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)\n\nThis 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.\n\nEmbeddings 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.\n\nLLM 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 <installing-plugins>`.\n\nThe first two embedding models supported by LLM are:\n\n- 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`.\n- 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.\n\nSee {ref}`embeddings-cli` for detailed instructions on working with embeddings using LLM.\n\nThe new commands for working with embeddings are:\n\n- **{ref}`llm embed <embeddings-cli-embed>`** - calculate embeddings for content and return them to the console or store them in a SQLite database.\n- **{ref}`llm embed-multi <embeddings-cli-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)\n- **{ref}`llm similar <embeddings-cli-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)\n- **{ref}`llm embed-models <embeddings-cli-embed-models>`** - list available embedding models.\n- `llm embed-db` - commands for inspecting and working with the default embeddings SQLite database.\n\nThere's also a new {ref}`llm.Collection <embeddings-python-collections>` class for creating and searching collections of embedding from Python code, and a {ref}`llm.get_embedding_model() <embeddings-python-api>` interface for embedding strings directly. [#191](https://github.com/simonw/llm/issues/191)\n\n(v0_8_1)=\n## 0.8.1 (2023-08-31)\n\n- 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)\n- 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)\n\n(v0_8)=\n## 0.8 (2023-08-20)\n\n- 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)\n  - The new `llm logs --json` option can be used to get the old JSON format.\n  - Pass `llm logs --conversation ID` or `--cid ID` to see the full logs for a specific conversation.\n- 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 <usage-system-prompts>`. [#153](https://github.com/simonw/llm/issues/153)\n- 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.\n- Keys set in `keys.json` are now used in preference to environment variables. [#158](https://github.com/simonw/llm/issues/158)\n- The documentation now includes a {ref}`plugin directory <plugin-directory>` listing all available plugins for LLM. [#173](https://github.com/simonw/llm/issues/173)\n- New {ref}`related tools <related-tools>` section in the documentation describing `ttok`, `strip-tags` and `symbex`. [#111](https://github.com/simonw/llm/issues/111)\n- 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)\n- New `llm keys` (aka `llm keys list`) command for listing the names of all configured keys. [#174](https://github.com/simonw/llm/issues/174)\n- 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)\n- 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)\n- `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)\n\n(v0_7_1)=\n## 0.7.1 (2023-08-19)\n\n- 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)\n\n(v0_7)=\n## 0.7 (2023-08-12)\n\nThe new {ref}`aliases` commands can be used to configure additional aliases for models, for example:\n\n```bash\nllm aliases set turbo gpt-3.5-turbo-16k\n```\nNow you can run the 16,000 token `gpt-3.5-turbo-16k` model like this:\n\n```bash\nllm -m turbo 'An epic Greek-style saga about a cheesecake that builds a SQL database from scratch'\n```\nUse `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)\n\n### Notable new plugins\n\n- **[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.\n- **[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.\n\n### Also in this release\n\n- 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)\n- 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)\n- Fixed bug in `llm install --editable` option which prevented installation of `.[test]`. [#136](https://github.com/simonw/llm/issues/136)\n- `llm install --no-cache-dir` and `--force-reinstall` options. [#146](https://github.com/simonw/llm/issues/146)\n\n(v0_6_1)=\n## 0.6.1 (2023-07-24)\n\n- LLM can now be installed directly from Homebrew core: `brew install llm`. [#124](https://github.com/simonw/llm/issues/124)\n- Python API documentation now covers {ref}`python-api-system-prompts`.\n- Fixed incorrect example in the {ref}`prompt-templates` documentation. Thanks, Jorge Cabello. [#125](https://github.com/simonw/llm/pull/125)\n\n(v0_6)=\n## 0.6 (2023-07-18)\n\n- 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/).\n- 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 <openai-compatible-models>` for the default OpenAI plugin. [#106](https://github.com/simonw/llm/issues/106)\n- OpenAI models that are not yet supported by LLM can also {ref}`be configured <openai-extra-models>` using the new `extra-openai-models.yaml` configuration file. [#107](https://github.com/simonw/llm/issues/107)\n- The {ref}`llm logs command <logging-view>` 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)\n- 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)\n\n(v0_5)=\n## 0.5 (2023-07-12)\n\nLLM now supports **additional language models**, thanks to a new {ref}`plugins mechanism <installing-plugins>` for installing additional models.\n\nPlugins are available for 19 models in addition to the default OpenAI ones:\n\n- [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.\n- [llm-mpt30b](https://github.com/simonw/llm-mpt30b) adds support for the MPT-30B model, a 19GB download.\n- [llm-palm](https://github.com/simonw/llm-palm) adds support for Google's PaLM 2 via the Google API.\n\nA comprehensive tutorial, {ref}`writing a plugin to support a new model <tutorial-model-plugin>` describes how to add new models by building plugins in detail.\n\n### New features\n\n- {ref}`python-api` documentation for using LLM models, including models from plugins, directly from Python. [#75](https://github.com/simonw/llm/issues/75)\n- 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)\n- 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)\n- 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)\n- `llm models list --options` command for viewing all available model options. [#82](https://github.com/simonw/llm/issues/82)\n- `llm \"prompt\" --save template` option for saving a prompt directly to a template. [#55](https://github.com/simonw/llm/issues/55)\n- Prompt templates can now specify {ref}`default values <prompt-default-parameters>` for parameters. Thanks,  Chris Mungall. [#57](https://github.com/simonw/llm/pull/57)\n- `llm openai models` command to list all available OpenAI models from their API. [#70](https://github.com/simonw/llm/issues/70)\n- `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)\n\n### Smaller improvements\n\n- `llm -s` is now a shortcut for `llm --system`. [#69](https://github.com/simonw/llm/issues/69)\n- `llm -m 4-32k` alias for `gpt-4-32k`.\n- `llm install -e directory` command for installing a plugin from a local directory.\n- 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)\n- Documentation covering {ref}`plugin-utilities`.\n- Documentation site now uses Plausible for analytics. [#79](https://github.com/simonw/llm/issues/79)\n\n(v0_4_1)=\n## 0.4.1 (2023-06-17)\n\n- LLM can now be installed using Homebrew: `brew install simonw/llm/llm`. [#50](https://github.com/simonw/llm/issues/50)\n- `llm` is now styled LLM in the documentation. [#45](https://github.com/simonw/llm/issues/45)\n- Examples in documentation now include a copy button. [#43](https://github.com/simonw/llm/issues/43)\n- `llm templates` command no longer has its display disrupted by newlines. [#42](https://github.com/simonw/llm/issues/42)\n- `llm templates` command now includes system prompt, if set. [#44](https://github.com/simonw/llm/issues/44)\n\n(v0_4)=\n## 0.4 (2023-06-17)\n\nThis release includes some backwards-incompatible changes:\n\n- The `-4` option for GPT-4 is now `-m 4`.\n- The `--code` option has been removed.\n- The `-s` option has been removed as streaming is now the default. Use `--no-stream` to opt out of streaming.\n\n### Prompt templates\n\n{ref}`prompt-templates` is a new feature that allows prompts to be saved as templates and re-used with different variables.\n\nTemplates can be created using the `llm templates edit` command:\n\n```bash\nllm templates edit summarize\n```\nTemplates are YAML - the following template defines summarization using a system prompt:\n\n```yaml\nsystem: Summarize this text\n```\nThe template can then be executed like this:\n```bash\ncat myfile.txt | llm -t summarize\n```\nTemplates 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.\n\nThis example adds a `voice` parameter:\n\n```yaml\nsystem: Summarize this text in the voice of $voice\n```\nThen to run it (via [strip-tags](https://github.com/simonw/strip-tags) to remove HTML tags from the input):\n```bash\ncurl -s 'https://til.simonwillison.net/macos/imovie-slides-and-audio' | \\\n  strip-tags -m | llm -t summarize -p voice GlaDOS\n```\nExample output:\n\n> My previous test subject seemed to have learned something new about iMovie. They exported keynote slides as individual images [...] Quite impressive for a human.\n\nThe {ref}`prompt-templates` documentation provides more detailed examples.\n\n### Continue previous chat\n\nYou 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.\n\nUse the new `-c/--continue` option to continue from the previous message thread:\n\n```bash\nllm \"Pretend to be a witty gerbil, say hi briefly\"\n```\n> Greetings, dear human! I am a clever gerbil, ready to entertain you with my quick wit and endless energy.\n```bash\nllm \"What do you think of snacks?\" -c\n```\n> 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!\n\nThe `-c` option will continue from the most recent logged message.\n\nTo 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.\n\nThanks [Amjith Ramanujam](https://github.com/amjith) for contributing to this feature. [#6](https://github.com/simonw/llm/issues/6)\n\n### New mechanism for storing API keys\n\nAPI keys for language models such as those by OpenAI can now be saved using the new `llm keys` family of commands.\n\nTo set the default key to be used for the OpenAI APIs, run this:\n\n```bash\nllm keys set openai\n```\nThen paste in your API key.\n\nKeys 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.\n\nSee {ref}`api-keys` for more. [#13](https://github.com/simonw/llm/issues/13)\n\n### New location for the logs.db database\n\nThe `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:\n\n```bash\nllm logs path\n```\nOn macOS this is `~/Library/Application Support/io.datasette.llm/logs.db`.\n\nTo open that database using Datasette, run this:\n\n```bash\ndatasette \"$(llm logs path)\"\n```\nYou can upgrade your existing installation by copying your database to the new location like this:\n```bash\ncp ~/.llm/log.db \"$(llm logs path)\"\nrm -rf ~/.llm # To tidy up the now obsolete directory\n```\nThe database schema has changed, and will be updated automatically the first time you run the command.\n\nThat schema is [included in the documentation](https://llm.datasette.io/en/stable/logging.html#sql-schema). [#35](https://github.com/simonw/llm/issues/35)\n\n### Other changes\n\n- 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)\n- Documentation now spans multiple pages and lives at <https://llm.datasette.io/> [#21](https://github.com/simonw/llm/issues/21)\n- Default `llm chatgpt` command has been renamed to `llm prompt`. [#17](https://github.com/simonw/llm/issues/17)\n- Removed `--code` option in favour of new prompt templates mechanism. [#24](https://github.com/simonw/llm/issues/24)\n- 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)\n- 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.\n- 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)\n- Improved display of error messages from OpenAI. [#15](https://github.com/simonw/llm/issues/15)\n\n(v0_3)=\n## 0.3 (2023-05-17)\n\n- `llm logs` command for browsing logs of previously executed completions. [#3](https://github.com/simonw/llm/issues/3)\n- `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)\n- Tool can now accept a prompt piped directly to standard input. [#11](https://github.com/simonw/llm/issues/11)\n\n(v0_2)=\n## 0.2 (2023-04-01)\n\n- 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)\n\n(v0_1)=\n## 0.1 (2023-04-01)\n\n- Initial prototype release. [#1](https://github.com/simonw/llm/issues/1)\n"
  },
  {
    "path": "docs/conf.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\nfrom subprocess import PIPE, Popen\n\n# This file is execfile()d with the current directory set to its\n# containing dir.\n#\n# Note that not all possible configuration values are present in this\n# autogenerated file.\n#\n# All configuration values have a default; values that are commented out\n# serve to show the default.\n\n# If extensions (or modules to document with autodoc) are in another directory,\n# add these directories to sys.path here. If the directory is relative to the\n# documentation root, use os.path.abspath to make it absolute, like shown here.\n#\n# import os\n# import sys\n# sys.path.insert(0, os.path.abspath('.'))\n\n\n# -- General configuration ------------------------------------------------\n\n# If your documentation needs a minimal Sphinx version, state it here.\n#\n# needs_sphinx = '1.0'\n\n# Add any Sphinx extension module names here, as strings. They can be\n# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom\n# ones.\nextensions = [\n    \"myst_parser\",\n    \"sphinx_copybutton\",\n    \"sphinx_markdown_builder\",\n    \"sphinx.ext.autodoc\",\n]\nmyst_enable_extensions = [\"colon_fence\"]\n\nmarkdown_http_base = \"https://llm.datasette.io/en/stable\"\nmarkdown_uri_doc_suffix = \".html\"\n\n# Add any paths that contain templates here, relative to this directory.\ntemplates_path = [\"_templates\"]\n\n# The suffix(es) of source filenames.\n# You can specify multiple suffix as a list of string:\n#\n# source_suffix = ['.rst', '.md']\nsource_suffix = \".rst\"\n\n# The master toctree document.\nmaster_doc = \"index\"\n\n# General information about the project.\nproject = \"LLM\"\ncopyright = \"2025, Simon Willison\"\nauthor = \"Simon Willison\"\n\n# The version info for the project you're documenting, acts as replacement for\n# |version| and |release|, also used in various other places throughout the\n# built documents.\n#\n# The short X.Y version.\npipe = Popen(\"git describe --tags --always\", stdout=PIPE, shell=True)\ngit_version = pipe.stdout.read().decode(\"utf8\")\n\nif git_version:\n    version = git_version.rsplit(\"-\", 1)[0]\n    release = git_version\nelse:\n    version = \"\"\n    release = \"\"\n\n# The language for content autogenerated by Sphinx. Refer to documentation\n# for a list of supported languages.\n#\n# This is also used if you do content translation via gettext catalogs.\n# Usually you set \"language\" from the command line for these cases.\nlanguage = \"en\"\n\n# List of patterns, relative to source directory, that match files and\n# directories to ignore when looking for source files.\n# This patterns also effect to html_static_path and html_extra_path\nexclude_patterns = [\"_build\", \"Thumbs.db\", \".DS_Store\"]\n\n# The name of the Pygments (syntax highlighting) style to use.\npygments_style = \"sphinx\"\n\n# If true, `todo` and `todoList` produce output, else they produce nothing.\ntodo_include_todos = False\n\n\n# -- Options for HTML output ----------------------------------------------\n\n# The theme to use for HTML and HTML Help pages.  See the documentation for\n# a list of builtin themes.\n#\nhtml_theme = \"furo\"\n\n# Theme options are theme-specific and customize the look and feel of a theme\n# further.  For a list of options available for each theme, see the\n# documentation.\n\nhtml_theme_options = {}\nhtml_title = \"LLM\"\n\n# Add any paths that contain custom static files (such as style sheets) here,\n# relative to this directory. They are copied after the builtin static files,\n# so a file named \"default.css\" will overwrite the builtin \"default.css\".\nhtml_static_path = []\n\n\n# -- Options for HTMLHelp output ------------------------------------------\n\n# Output file base name for HTML help builder.\nhtmlhelp_basename = \"llm-doc\"\n\n\n# -- Options for LaTeX output ---------------------------------------------\n\nlatex_elements = {\n    # The paper size ('letterpaper' or 'a4paper').\n    #\n    # 'papersize': 'letterpaper',\n    # The font size ('10pt', '11pt' or '12pt').\n    #\n    # 'pointsize': '10pt',\n    # Additional stuff for the LaTeX preamble.\n    #\n    # 'preamble': '',\n    # Latex figure (float) alignment\n    #\n    # 'figure_align': 'htbp',\n}\n\n# Grouping the document tree into LaTeX files. List of tuples\n# (source start file, target name, title,\n#  author, documentclass [howto, manual, or own class]).\nlatex_documents = [\n    (\n        master_doc,\n        \"llm.tex\",\n        \"LLM documentation\",\n        \"Simon Willison\",\n        \"manual\",\n    )\n]\n\n\n# -- Options for manual page output ---------------------------------------\n\n# One entry per manual page. List of tuples\n# (source start file, name, description, authors, manual section).\nman_pages = [\n    (\n        master_doc,\n        \"llm\",\n        \"LLM documentation\",\n        [author],\n        1,\n    )\n]\n\n\n# -- Options for Texinfo output -------------------------------------------\n\n# Grouping the document tree into Texinfo files. List of tuples\n# (source start file, target name, title, author,\n#  dir menu entry, description, category)\ntexinfo_documents = [\n    (\n        master_doc,\n        \"llm\",\n        \"LLM documentation\",\n        author,\n        \"llm\",\n        \" Access large language models from the command-line \",\n        \"Miscellaneous\",\n    )\n]\n"
  },
  {
    "path": "docs/contributing.md",
    "content": "(contributing)=\n# Contributing\n\nTo contribute to this tool, first checkout the code. Then run the tests with `uv run`:\n```bash\ncd llm\nuv run pytest\n```\nYou can run your development copy of `llm` using `uv run` as well:\n```bash\nuv run llm --help\n```\n\n## Updating recorded HTTP API interactions and associated snapshots\n\nThis 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.\n\nIf you add a new test that calls the API you can capture the API response and snapshot like this:\n```bash\nPYTEST_OPENAI_API_KEY=\"$(llm keys get openai)\" uv run pytest --record-mode once --snapshot-update\n```\nThen review the new snapshots in `tests/__snapshots__/` to make sure they look correct.\n\n## Debugging tricks\n\nThe default OpenAI plugin has a debugging mechanism for showing the exact requests and responses that were sent to the OpenAI API.\n\nSet the `LLM_OPENAI_SHOW_RESPONSES` environment variable like this:\n```bash\nLLM_OPENAI_SHOW_RESPONSES=1 uv run llm -m chatgpt 'three word slogan for an an otter-run bakery'\n```\nThis will output details of the API requests and responses to the console.\n\nUse `--no-stream` to see a more readable version of the body that avoids streaming the response:\n\n```bash\nLLM_OPENAI_SHOW_RESPONSES=1 uv run llm -m chatgpt --no-stream \\\n  'three word slogan for an an otter-run bakery'\n```\n\n## Documentation\n\nDocumentation for this project uses [MyST](https://myst-parser.readthedocs.io/) - it is written in Markdown and rendered using Sphinx.\n\nTo build the documentation locally, run the following:\n```bash\njust docs\n```\nThis will start a live preview server, using [sphinx-autobuild](https://pypi.org/project/sphinx-autobuild/).\n\nThe CLI `--help` examples in the documentation are managed using [Cog](https://github.com/nedbat/cog). Update those files like this:\n```bash\njust cog\n```\nYou'll need [Just](https://github.com/casey/just) installed to run these commands.\n\n## Release process\n\nTo release a new version:\n\n1. Update `docs/changelog.md` with the new changes.\n2. Update the version number in `pyproject.toml`\n3. Run `just cog` to update `docs/fragments.md` with the new version number.\n4. [Create a GitHub release](https://github.com/simonw/llm/releases/new) for the new version.\n5. Wait for the package to push to PyPI and then...\n6. Run the [regenerate.yaml](https://github.com/simonw/homebrew-llm/actions/workflows/regenerate.yaml) workflow to update the Homebrew tap to the latest version.\n"
  },
  {
    "path": "docs/embeddings/cli.md",
    "content": "(embeddings-cli)=\n# Embedding with the CLI\n\nLLM provides command-line utilities for calculating and storing embeddings for pieces of content.\n\n(embeddings-cli-embed)=\n## llm embed\n\nThe `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.\n\n### Returning embeddings to the terminal\n\nThe simplest way to use this command is to pass content to it using the `-c/--content` option, like this:\n\n```bash\nllm embed -c 'This is some content' -m 3-small\n```\n`-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.\n\nYou 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:\n\n```bash\nllm install llm-sentence-transformers\nllm embed -c 'This is some content' -m sentence-transformers/all-MiniLM-L6-v2\n```\n\nThe `llm embed` command returns a JSON array of floating point numbers directly to the terminal:\n\n```json\n[0.123, 0.456, 0.789...]\n```\nYou can omit the `-m/--model` option if you set a {ref}`default embedding model <embeddings-cli-embed-models-default>`.\n\nYou can also set the `LLM_EMBEDDING_MODEL` environment variable to set a default model for all `llm embed` commands in the current shell session:\n\n```bash\nexport LLM_EMBEDDING_MODEL=3-small\nllm embed -c 'This is some content'\n```\n\nLLM also offers a binary storage format for embeddings, described in {ref}`embeddings storage format <embeddings-storage>`.\n\nYou can output embeddings using that format as raw bytes using `--format blob`, or in hexadecimal using `--format hex`, or in Base64 using `--format base64`:\n\n```bash\nllm embed -c 'This is some content' -m 3-small --format base64\n```\nThis outputs:\n```\n8NGzPFtdgTqHcZw7aUT6u+++WrwwpZo8XbSxv...\n```\nSome 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:\n\n```bash\nllm embed --binary -m clip -i image.jpg\n```\nOr from standard input like this:\n```bash\ncat image.jpg | llm embed --binary -m clip -i -\n```\n\n(embeddings-collections)=\n### Storing embeddings in SQLite\n\nEmbeddings are much more useful if you store them somewhere, so you can calculate similarity scores between different embeddings later on.\n\nLLM 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.\n\nEmbeddings also store a hash of the content that was embedded. This hash is later used to avoid calculating duplicate embeddings for the same content.\n\nFirst, we'll set a default model so we don't have to keep repeating it:\n```bash\nllm embed-models default 3-small\n```\n\nThe `llm embed` command can store results directly in a named collection like this:\n\n```bash\nllm embed quotations philkarlton-1 -c \\\n  'There are only two hard things in Computer Science: cache invalidation and naming things'\n```\nThis stores the given text in the `quotations` collection under the key `philkarlton-1`.\n\nYou can also pipe content to standard input, like this:\n```bash\ncat one.txt | llm embed files one\n```\nThis will store the embedding for the contents of `one.txt` in the `files` collection under the key `one`.\n\nA collection will be created the first time you mention it.\n\nCollections have a fixed embedding model, which is the model that was used for the first embedding stored in that collection.\n\nIn the above example this would have been the default embedding model at the time that the command was run.\n\nThe 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`:\n\n```bash\nllm embed phrases hound -m 3-small -c 'my happy hound'\n```\nBy default, the SQLite database used to store embeddings is the `embeddings.db` in the user content directory managed by LLM.\n\nYou can see the path to this directory by running `llm collections path`.\n\nYou 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:\n\n```bash\nllm embed phrases hound -d my-embeddings.db -c 'my happy hound'\n```\nThis creates a database file called `my-embeddings.db` in the current directory.\n\n(embeddings-collections-content-metadata)=\n#### Storing content and metadata\n\nBy default, only the entry ID and the embedding vector are stored in the database table.\n\nYou can store a copy of the original text in the `content` column by passing the `--store` option:\n\n```bash\nllm embed phrases hound -c 'my happy hound' --store\n```\nYou 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:\n\n```bash\nllm embed phrases hound \\\n  -m 3-small \\\n  -c 'my happy hound' \\\n  --metadata '{\"name\": \"Hound\"}' \\\n  --store\n```\nData stored in this way will be returned by calls to `llm similar`, for example:\n```bash\nllm similar phrases -c 'hound'\n```\n```\n{\"id\": \"hound\", \"score\": 0.8484683588631485, \"content\": \"my happy hound\", \"metadata\": {\"name\": \"Hound\"}}\n```\n\n(embeddings-cli-embed-multi)=\n## llm embed-multi\n\nThe `llm embed` command embeds a single string at a time.\n\n`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.\n\nThis command can be called in one of three ways:\n\n1. With a CSV, TSV, JSON or newline-delimited JSON file\n2. With a SQLite database and a SQL query\n3. With one or more paths to directories, each accompanied by a glob pattern\n\nAll three mechanisms support these options:\n\n- `-m model_id` to specify the embedding model to use\n- `-d database.db` to specify a different database file to store the embeddings in\n- `--store` to store the original content in the embeddings table in addition to the embedding vector\n- `--prefix` to prepend a prefix to the stored ID of each item\n- `--prepend` to prepend a string to the content before embedding \n- `--batch-size SIZE` to process embeddings in batches of the specified size\n\nThe `--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: '`.\n\n(embeddings-cli-embed-multi-csv-etc)=\n### Embedding data from a CSV, TSV or JSON file\n\nYou 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.\n\nYour 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.\n\nAn example CSV file might look like this:\n\n```\nid,content\none,This is the first item\ntwo,This is the second item\n```\nTSV would use tabs instead of commas.\n\nJSON files can be structured like this:\n\n```json\n[\n  {\"id\": \"one\", \"content\": \"This is the first item\"},\n  {\"id\": \"two\", \"content\": \"This is the second item\"}\n]\n```\nOr as newline-delimited JSON like this:\n```json\n{\"id\": \"one\", \"content\": \"This is the first item\"}\n{\"id\": \"two\", \"content\": \"This is the second item\"}\n```\nIn each of these cases the file can be passed to `llm embed-multi` like this:\n```bash\nllm embed-multi items mydata.csv\n```\nThe first argument is the name of the collection, the second is the filename.\n\nYou can also pipe content to standard input of the tool using `-`:\n\n```bash\ncat mydata.json | llm embed-multi items -\n```\nLLM 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.\n\n```bash\ncat mydata.json | llm embed-multi items - --format nl\n```\nOther supported `--format` options are `csv`, `tsv` and `json`.\n\nThis 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:\n\n```bash\nllm embed-multi items mydata.json \\\n  -d docs.db \\\n  -m 3-small \\\n  --prefix my-items/ \\\n  --store\n```\n\n(embeddings-cli-embed-multi-sqlite)=\n### Embedding data from a SQLite database\n\nYou can embed data from a SQLite database using `--sql`, optionally combined with `--attach` to attach an additional database.\n\nIf you are storing embeddings in the same database as the source data, you can do this:\n\n```bash\nllm embed-multi docs \\\n  -d docs.db \\\n  --sql 'select id, title, content from documents' \\\n  -m 3-small\n```\nThe `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.\n\nTo 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:\n\n```bash\nllm embed-multi docs \\\n  -d embeddings.db \\\n  --attach other other.db \\\n  --sql 'select id, title, content from other.documents' \\\n  -m 3-small\n```\n\n(embeddings-cli-embed-multi-directories)=\n### Embedding data from files in directories\n\nLLM can embed the content of every text file in a specified directory, using the file's path and name as the ID.\n\nConsider a directory structure like this:\n```\ndocs/aliases.md\ndocs/contributing.md\ndocs/embeddings/binary.md\ndocs/embeddings/cli.md\ndocs/embeddings/index.md\ndocs/index.md\ndocs/logging.md\ndocs/plugins/directory.md\ndocs/plugins/index.md\n```\nTo embed all of those documents, you can run the following:\n\n```bash\nllm embed-multi documentation \\\n  -m 3-small \\\n  --files docs '**/*.md' \\\n  -d documentation.db \\\n  --store\n```\nHere `--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.\n\nThe result of the above command is a `embeddings` table with the following IDs:\n\n```\naliases.md\ncontributing.md\nembeddings/binary.md\nembeddings/cli.md\nembeddings/index.md\nindex.md\nlogging.md\nplugins/directory.md\nplugins/index.md\n```\nEach corresponding to embedded content for the file in question.\n\nThe `--prefix` option can be used to add a prefix to each ID:\n\n```bash\nllm embed-multi documentation \\\n  -m 3-small \\\n  --files docs '**/*.md' \\\n  -d documentation.db \\\n  --store \\\n  --prefix llm-docs/\n```\nThis will result in the following IDs instead:\n\n```\nllm-docs/aliases.md\nllm-docs/contributing.md\nllm-docs/embeddings/binary.md\nllm-docs/embeddings/cli.md\nllm-docs/embeddings/index.md\nllm-docs/index.md\nllm-docs/logging.md\nllm-docs/plugins/directory.md\nllm-docs/plugins/index.md\n```\nFiles 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.\n\nThis example will try `utf-16` first and then `mac_roman` before falling back to `latin-1`:\n```\nllm embed-multi documentation \\\n  -m 3-small \\\n  --files docs '**/*.md' \\\n  -d documentation.db \\\n  --encoding utf-16 \\\n  --encoding mac_roman \\\n  --encoding latin-1\n```\nIf a file cannot be read it will be logged to standard error but the script will keep on running.\n\nIf you are embedding binary content such as images for use with CLIP, add the `--binary` option:\n```\nllm embed-multi photos \\\n  -m clip \\\n  --files photos/ '*.jpeg' --binary\n```\n\n(embeddings-cli-similar)=\n## llm similar\n\nThe `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).\n\nThis 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.\n\nTo search the `quotations` collection for items that are semantically similar to `'computer science'`:\n\n```bash\nllm similar quotations -c 'computer science'\n```\nThis embeds the provided string and returns a newline-delimited list of JSON objects like this:\n```json\n{\"id\": \"philkarlton-1\", \"score\": 0.8323904531677017, \"content\": null, \"metadata\": null}\n```\nUse `-p/--plain` to get back results in plain text instead of JSON:\n```bash\nllm similar quotations -c 'computer science' -p\n```\nExample output:\n```\nphilkarlton-1 (0.8323904531677017)\n```\nYou can compare against text stored in a file using `-i filename`:\n```bash\nllm similar quotations -i one.txt\n```\nOr feed text to standard input using `-i -`:\n```bash\necho 'computer science' | llm similar quotations -i -\n```\nWhen using a model like CLIP, you can find images similar to an input image using `-i filename` with `--binary`:\n```bash\nllm similar photos -i image.jpg --binary\n```\n\nYou can filter results to only show IDs that begin with a specific prefix using --prefix:\n\n```bash\nllm similar quotations --prefix 'movies/' -c 'star wars'\n```\n\n(embeddings-cli-embed-models)=\n## llm embed-models\n\nTo list all available embedding models, including those provided by plugins, run this command:\n\n```bash\nllm embed-models\n```\nThe output should look something like this:\n```\nOpenAIEmbeddingModel: text-embedding-ada-002 (aliases: ada, ada-002)\nOpenAIEmbeddingModel: text-embedding-3-small (aliases: 3-small)\nOpenAIEmbeddingModel: text-embedding-3-large (aliases: 3-large)\n...\n```\nAdd `-q` one or more times to search for models matching those terms:\n```bash\nllm embed-models -q 3-small\n```\n\n(embeddings-cli-embed-models-default)=\n### llm embed-models default\n\nThis command can be used to get and set the default embedding model.\n\nThis will return the name of the current default model:\n```bash\nllm embed-models default\n```\nYou can set a different default like this:\n```bash\nllm embed-models default 3-small\n```\nThis will set the default model to OpenAI's `3-small` model.\n\nAny of the supported aliases for a model can be passed to this command.\n\nYou can unset the default model using `--remove-default`:\n\n```bash\nllm embed-models default --remove-default\n```\nWhen no default model is set, the `llm embed` and `llm embed-multi` commands will require that a model is specified using `-m/--model`.\n\n## llm collections list\n\nTo list all of the collections in the embeddings database, run this command:\n\n```bash\nllm collections list\n```\nAdd `--json` for JSON output:\n```bash\nllm collections list --json\n```\nAdd `-d/--database` to specify a different database file:\n```bash\nllm collections list -d my-embeddings.db\n```\n## llm collections delete\n\nTo delete a collection from the database, run this:\n```bash\nllm collections delete collection-name\n```\nPass `-d` to specify a different database file:\n```bash\nllm collections delete collection-name -d my-embeddings.db\n```\n"
  },
  {
    "path": "docs/embeddings/index.md",
    "content": "(embeddings)=\n# Embeddings\n\nEmbedding 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.\n\nThis 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.\n\nBy calculating the distance between embedding vectors, we can identify which content is semantically \"nearest\" to other content.\n\nThis 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.\n\nSome 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.\n\nLLM supports multiple embedding models through {ref}`plugins <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.\n\nSee [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.\n\n```{toctree}\n---\nmaxdepth: 3\n---\ncli\npython-api\nwriting-plugins\nstorage\n```\n"
  },
  {
    "path": "docs/embeddings/python-api.md",
    "content": "(embeddings-python-api)=\n# Using embeddings from Python\n\nYou can load an embedding model using its model ID or alias like this:\n```python\nimport llm\n\nembedding_model = llm.get_embedding_model(\"3-small\")\n```\nTo embed a string, returning a Python list of floating point numbers, use the `.embed()` method:\n```python\nvector = embedding_model.embed(\"my happy hound\")\n```\nIf 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:\n```python\nif embedding_model.supports_binary:\n    vector = embedding_model.embed(open(\"my-image.jpg\", \"rb\").read())\n```\nThe `embedding_model.supports_text` property indicates if the model supports text input.\n\nMany 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:\n```python\nvectors = list(embedding_model.embed_multi([\"my happy hound\", \"my dissatisfied cat\"]))\n```\nThis returns a generator that yields one embedding vector per string.\n\nEmbeddings 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:\n\n```python\nvectors = list(embedding_model.embed_multi(lines_from_file, batch_size=20))\n```\n\n(embeddings-python-collections)=\n## Working with collections\n\nThe `llm.Collection` class can be used to work with **collections** of embeddings from Python code.\n\nA collection is a named group of embedding vectors, each stored along with their IDs in a SQLite database table.\n\nTo 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:\n\n```python\nimport sqlite_utils\nimport llm\n\n# This collection will use an in-memory database that will be\n# discarded when the Python process exits\ncollection = llm.Collection(\"entries\", model_id=\"3-small\")\n\n# Or you can persist the database to disk like this:\ndb = sqlite_utils.Database(\"my-embeddings.db\")\ncollection = llm.Collection(\"entries\", db, model_id=\"3-small\")\n\n# You can pass a model directly using model= instead of model_id=\nembedding_model = llm.get_embedding_model(\"3-small\")\ncollection = llm.Collection(\"entries\", db, model=embedding_model)\n```\nIf 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.\n\nTo embed a single string and store it in the collection, use the `embed()` method:\n\n```python\ncollection.embed(\"hound\", \"my happy hound\")\n```\nThis stores the embedding for the string \"my happy hound\" in the `entries` collection under the key `hound`.\n\nAdd `store=True` to store the text content itself in the database table along with the embedding vector.\n\nTo attach additional metadata to an item, pass a JSON-compatible dictionary as the `metadata=` argument:\n\n```python\ncollection.embed(\"hound\", \"my happy hound\", metadata={\"name\": \"Hound\"}, store=True)\n```\nThis additional metadata will be stored as JSON in the `metadata` column of the embeddings database table.\n\n(embeddings-python-bulk)=\n### Storing embeddings in bulk\n\nThe `collection.embed_multi()` method can be used to store embeddings for multiple items at once. This can be more efficient for some embedding models.\n\n```python\ncollection.embed_multi(\n    [\n        (\"hound\", \"my happy hound\"),\n        (\"cat\", \"my dissatisfied cat\"),\n    ],\n    # Add this to store the strings in the content column:\n    store=True,\n)\n```\nTo include metadata to be stored with each item, call `embed_multi_with_metadata()`:\n\n```python\ncollection.embed_multi_with_metadata(\n    [\n        (\"hound\", \"my happy hound\", {\"name\": \"Hound\"}),\n        (\"cat\", \"my dissatisfied cat\", {\"name\": \"Cat\"}),\n    ],\n    # This can also take the store=True argument:\n    store=True,\n)\n```\nThe `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:\n\n```python\ncollection.embed_multi(\n    (\n        (i, line)\n        for i, line in enumerate(lines_in_file)\n    ),\n    batch_size=10\n)\n```\n\n(embeddings-python-collection-class)=\n### Collection class reference\n\nA collection instance has the following properties and methods:\n\n- `id` - the integer ID of the collection in the database\n- `name` - the string name of the collection (unique in the database)\n- `model_id` - the string ID of the embedding model used for this collection\n- `model()` - returns the `EmbeddingModel` instance, based on that `model_id`\n- `count()` - returns the integer number of items in the collection\n- `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.\n- `embed_multi(entries: Iterable, store: bool=False, batch_size: int=100)` - see above\n- `embed_multi_with_metadata(entries: Iterable, store: bool=False, batch_size: int=100)` - see above\n- `similar(query: str, number: int=10)` - returns a list of entries that are most similar to the embedding of the given query string\n- `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\n- `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\n- `delete()` - deletes the collection and its embeddings from the database\n\nThere 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:\n\n```python\nif Collection.exists(db, \"entries\"):\n    print(\"The entries collection exists\")\n```\n\n(embeddings-python-similar)=\n## Retrieving similar items\n\nOnce you have populated a collection of embeddings you can retrieve the entries that are most similar to a given string using the `similar()` method.\n\nThis 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.\n\n```python\nfor entry in collection.similar(\"hound\"):\n    print(entry.id, entry.score)\n```\nThe string will first by embedded using the model for the collection.\n\nThe `entry` object returned is an object with the following properties:\n\n- `id` - the string ID of the item\n- `score` - the floating point similarity score between the item and the query string\n- `content` - the string text content of the item, if it was stored - or `None`\n- `metadata` - the dictionary (from JSON) metadata for the item, if it was stored - or `None`\n\nThis defaults to returning the 10 most similar items. You can change this by passing a different `number=` argument:\n```python\nfor entry in collection.similar(\"hound\", number=5):\n    print(entry.id, entry.score)\n```\nThe `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:\n\n```python\nfor entry in collection.similar_by_id(\"cat\"):\n    print(entry.id, entry.score)\n```\nThe item itself is excluded from the results.\n\n(embeddings-sql-schema)=\n## SQL schema\n\nHere's the SQL schema used by the embeddings database:\n\n<!-- [[[cog\nimport cog\nfrom llm.embeddings_migrations import embeddings_migrations\nimport sqlite_utils\nimport re\ndb = sqlite_utils.Database(memory=True)\nembeddings_migrations.apply(db)\n\ncog.out(\"```sql\\n\")\nfor table in (\"collections\", \"embeddings\"):\n    schema = db[table].schema\n    cog.out(format(schema))\n    cog.out(\"\\n\")\ncog.out(\"```\\n\")\n]]] -->\n```sql\nCREATE TABLE [collections] (\n   [id] INTEGER PRIMARY KEY,\n   [name] TEXT,\n   [model] TEXT\n)\nCREATE TABLE \"embeddings\" (\n   [collection_id] INTEGER REFERENCES [collections]([id]),\n   [id] TEXT,\n   [embedding] BLOB,\n   [content] TEXT,\n   [content_blob] BLOB,\n   [content_hash] BLOB,\n   [metadata] TEXT,\n   [updated] INTEGER,\n   PRIMARY KEY ([collection_id], [id])\n)\n```\n<!-- [[[end]]] -->\n"
  },
  {
    "path": "docs/embeddings/storage.md",
    "content": "(embeddings-storage)=\n# Embedding storage format\n\nThe default output format of the `llm embed` command is a JSON array of floating point numbers.\n\nLLM stores embeddings in space-efficient format: a little-endian binary sequences of 32-bit floating point numbers, each represented using 4 bytes.\n\nThese are stored in a `BLOB` column in a SQLite database.\n\nThe following Python functions can be used to convert between this format and an array of floating point numbers:\n\n```python\nimport struct\n\ndef encode(values):\n    return struct.pack(\"<\" + \"f\" * len(values), *values)\n\ndef decode(binary):\n    return struct.unpack(\"<\" + \"f\" * (len(binary) // 4), binary)\n```\n\nThese functions are available as `llm.encode()` and `llm.decode()`.\n\nIf you are using [NumPy](https://numpy.org/) you can decode one of these binary values like this:\n\n```python\nimport numpy as np\n\nnumpy_array = np.frombuffer(value, \"<f4\")\n```\nThe `<f4` format string here ensures NumPy will treat the data as a little-endian sequence of 32-bit floats."
  },
  {
    "path": "docs/embeddings/writing-plugins.md",
    "content": "(embeddings-writing-plugins)=\n# Writing plugins to add new embedding models\n\nRead the {ref}`plugin tutorial <tutorial-model-plugin>` for details on how to develop and package a plugin.\n\nThis page shows an example plugin that implements and registers a new embedding model.\n\nThere are two components to an embedding model plugin:\n\n1. 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.\n2. A class that extends the `llm.EmbeddingModel` abstract base class.\n\n    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.\n\nThe 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.\n\n```python\nimport llm\nfrom sentence_transformers import SentenceTransformer\n\n\n@llm.hookimpl\ndef register_embedding_models(register):\n    model_id = \"sentence-transformers/all-MiniLM-L6-v2\"\n    register(SentenceTransformerModel(model_id, model_id), aliases=(\"all-MiniLM-L6-v2\",))\n\n\nclass SentenceTransformerModel(llm.EmbeddingModel):\n    def __init__(self, model_id, model_name):\n        self.model_id = model_id\n        self.model_name = model_name\n        self._model = None\n\n    def embed_batch(self, texts):\n        if self._model is None:\n            self._model = SentenceTransformer(self.model_name)\n        results = self._model.encode(texts)\n        return (list(map(float, result)) for result in results)\n```\nOnce installed, the model provided by this plugin can be used with the {ref}`llm embed <embeddings-cli-embed>` command like this:\n\n```bash\ncat file.txt | llm embed -m sentence-transformers/all-MiniLM-L6-v2\n```\nOr via its registered alias like this:\n```bash\ncat file.txt | llm embed -m all-MiniLM-L6-v2\n```\n[llm-sentence-transformers](https://github.com/simonw/llm-sentence-transformers) is a complete example of a plugin that provides an embedding model.\n\n[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/).\n\n## Embedding binary content\n\nIf your model can embed binary content, use the `supports_binary` property to indicate that:\n\n```python\nclass ClipEmbeddingModel(llm.EmbeddingModel):\n    model_id = \"clip\"\n    supports_binary = True\n    supports_text= True\n```\n\n`supports_text` defaults to `True` and so is not necessary here. You can set it to `False` if your model only supports binary data.\n\nIf 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.\n\n[llm-clip](https://github.com/simonw/llm-clip) is an example of a model that can embed both binary and text content.\n"
  },
  {
    "path": "docs/fragments.md",
    "content": "(fragments)=\n# Fragments\n\nLLM 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.\n\nThese are particularly useful when you are working with long context models, which support feeding large amounts of text in as part of your prompt.\n\nFragments primarily exist to save space in the database, but may be used to support other features such as vendor prompt caching as well.\n\nFragments can be specified using several different mechanisms:\n\n- URLs to text files online\n- Paths to text files on disk\n- Aliases that have been attached to a specific fragment\n- Hash IDs of stored fragments, where the ID is the SHA256 hash of the fragment content\n- Fragments that are provided by custom plugins - these look like `plugin-name:argument`\n\n(fragments-usage)=\n## Using fragments in a prompt\n\nUse the `-f/--fragment` option to specify one or more fragments to be used as part of your prompt:\n\n```bash\nllm -f https://llm.datasette.io/robots.txt \"Explain this robots.txt file in detail\"\n```\nHere 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.\n\n<!--[[[cog\nfrom importlib.metadata import version\nllm_version = version(\"llm\")\ncog.out(f'The URL will be fetched with the user-agent `llm/{llm_version} (https://llm.datasette.io/)`.')\n]]]-->\nThe URL will be fetched with the user-agent `llm/0.28 (https://llm.datasette.io/)`.\n<!--[[[end]]]-->\n\nThe `-f` option can be used multiple times to combine together multiple fragments.\n\nFragments can also be files on disk, for example:\n```bash\nllm -f setup.py 'extract the metadata'\n```\nUse `-` to specify a fragment that is read from standard input:\n```bash\nllm -f - 'extract the metadata' < setup.py\n```\nThis will read the contents of `setup.py` from standard input and use it as a fragment.\n\nFragments can also be used as part of your system prompt. Use `--sf value` or `--system-fragment value` instead of `-f`.\n\n## Using fragments in chat\n\nThe `chat` command also supports the `-f` and `--sf` arguments to start a chat with fragments.\n\n```bash\nllm chat -f my_doc.txt\nChatting with gpt-4\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 <my_fragment> [<another_fragment> ...]' to insert one or more fragments\n> Explain this document to me\n```\n\nFragments can also be added *during* a chat conversation using the `!fragment <my_fragment>` command.\n\n```bash\nChatting with gpt-4\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 <my_fragment> [<another_fragment> ...]' to insert one or more fragments\n> !fragment https://llm.datasette.io/en/stable/fragments.html\n```\n\nThis can be combined with `!multi`:\n\n```bash\n> !multi\nExplain the difference between fragments and templates to me\n!fragment https://llm.datasette.io/en/stable/fragments.html https://llm.datasette.io/en/stable/templates.html\n!end\n```\n\nAny `!fragment` lines found in a prompt created with `!edit` will not be parsed.\n\n(fragments-browsing)=\n## Browsing fragments\n\nYou can view a truncated version of the fragments you have previously stored in your database with the `llm fragments` command:\n\n```bash\nllm fragments\n```\nThe output from that command looks like this:\n\n```yaml\n- hash: 0d6e368f9bc21f8db78c01e192ecf925841a957d8b991f5bf9f6239aa4d81815\n  aliases: []\n  datetime_utc: '2025-04-06 07:36:53'\n  source: https://raw.githubusercontent.com/simonw/llm-docs/refs/heads/main/llm/0.22.txt\n  content: |-\n    <documents>\n    <document index=\"1\">\n    <source>docs/aliases.md</source>\n    <document_content>\n    (aliases)=\n    #...\n- hash: 16b686067375182573e2aa16b5bfc1e64d48350232535d06444537e51f1fd60c\n  aliases: []\n  datetime_utc: '2025-04-06 23:03:47'\n  source: simonw/files-to-prompt/pyproject.toml\n  content: |-\n    [project]\n    name = \"files-to-prompt\"\n    version = \"0.6\"\n    description = \"Concatenate a directory full of...\n```\nThose long `hash` values are IDs that can be used to reference a fragment in the future:\n```bash\nllm -f 16b686067375182573e2aa16b5bfc1e64d48350232535d06444537e51f1fd60c 'Extract metadata'\n```\nUse `-q searchterm` one or more times to search for fragments that match a specific set of search terms.\n\nTo view the full content of a fragment use `llm fragments show`:\n```bash\nllm fragments show 0d6e368f9bc21f8db78c01e192ecf925841a957d8b991f5bf9f6239aa4d81815\n```\n\n(fragments-aliases)=\n## Setting aliases for fragments\n\nYou can assign aliases to fragments that you use often using the `llm fragments set` command:\n```bash\nllm fragments set mydocs ./docs.md\n```\nTo remove an alias, use `llm fragments remove`:\n```bash\nllm fragments remove mydocs\n```\nYou can then use that alias in place of the fragment hash ID:\n```bash\nllm -f mydocs 'How do I access metadata?'\n```\nUse `llm fragments --aliases` to see a full list of fragments that have been assigned aliases:\n```bash\nllm fragments --aliases\n```\n\n(fragments-logs)=\n## Viewing fragments in your logs\n\nThe `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.\n\nThis command will show the expanded fragments for your most recent conversation:\n\n```bash\nllm logs -c --expand\n```\nYou can filter for logs that used a specific fragment using the `-f/--fragment` option:\n```bash\nllm logs -c -f 0d6e368f9bc21f8db78c01e192ecf925841a957d8b991f5bf9f6239aa4d81815\n```\nThis accepts URLs, file paths, aliases, and hash IDs.\n\nMultiple `-f` options will return responses that used **all** of the specified fragments.\n\nFragments 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.\n\n```bash\nllm logs -c --json --expand\n```\n\n(fragments-plugins)=\n## Using fragments from plugins\n\nLLM plugins can provide custom fragment loaders which do useful things.\n\nOne 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.\n\nHere's how to try that out:\n\n```bash\nllm install llm-fragments-github\nllm -f github:simonw/s3-credentials 'Suggest new features for this tool'\n```\nThis 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.\n\nRunning `llm logs -c` will show that this prompt incorporated 26 fragments, one for each file.\n\nRunning `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).\n\nFragment plugins can return {ref}`attachments <usage-attachments>` (such as images) as well.\n\nSee the {ref}`register_fragment_loaders() plugin hook <plugin-hooks-register-fragment-loaders>` documentation for details on writing your own custom fragment plugin.\n\n(fragments-loaders)=\n## Listing available fragment prefixes\n\nThe `llm fragments loaders` command shows all prefixes that have been installed by plugins, along with their documentation:\n\n```bash\nllm install llm-fragments-github\nllm fragments loaders\n```\nExample output:\n```\ngithub:\n  Load files from a GitHub repository as fragments\n\n  Argument is a GitHub repository URL or username/repository\n\nissue:\n  Fetch GitHub issue and comments as Markdown\n\n  Argument is either \"owner/repo/NUMBER\"\n  or \"https://github.com/owner/repo/issues/NUMBER\"\n```\n"
  },
  {
    "path": "docs/help.md",
    "content": "# CLI reference\n\nThis page lists the `--help` output for all of the `llm` commands.\n\n<!-- [[[cog\nfrom click.testing import CliRunner\nfrom llm.cli import cli\ndef all_help(cli):\n    \"Return all help for Click command and its subcommands\"\n    # First find all commands and subcommands\n    # List will be [[\"command\"], [\"command\", \"subcommand\"], ...]\n    commands = []\n    def find_commands(command, path=None):\n        path = path or []\n        commands.append(path + [command.name])\n        if hasattr(command, 'commands'):\n            for subcommand in command.commands.values():\n                find_commands(subcommand, path + [command.name])\n    find_commands(cli)\n    # Remove first item of each list (it is 'cli')\n    commands = [command[1:] for command in commands]\n    # Now generate help for each one, with appropriate heading level\n    output = []\n    for command in commands:\n        heading_level = len(command) + 2\n        result = CliRunner().invoke(cli, command + [\"--help\"])\n        hyphenated = \"-\".join(command)\n        if hyphenated:\n            hyphenated = \"-\" + hyphenated\n        output.append(f\"\\n(help{hyphenated})=\")\n        output.append(\"#\" * heading_level + \" llm \" + \" \".join(command) + \" --help\")\n        output.append(\"```\")\n        output.append(result.output.replace(\"Usage: cli\", \"Usage: llm\").strip())\n        output.append(\"```\")\n    return \"\\n\".join(output)\ncog.out(all_help(cli))\n]]] -->\n\n(help)=\n## llm  --help\n```\nUsage: llm [OPTIONS] COMMAND [ARGS]...\n\n  Access Large Language Models from the command-line\n\n  Documentation: https://llm.datasette.io/\n\n  LLM can run models from many different providers. Consult the plugin directory\n  for a list of available models:\n\n  https://llm.datasette.io/en/stable/plugins/directory.html\n\n  To get started with OpenAI, obtain an API key from them and:\n\n      $ llm keys set openai\n      Enter key: ...\n\n  Then execute a prompt like this:\n\n      llm 'Five outrageous names for a pet pelican'\n\n  For a full list of prompting options run:\n\n      llm prompt --help\n\nOptions:\n  --version   Show the version and exit.\n  -h, --help  Show this message and exit.\n\nCommands:\n  prompt*       Execute a prompt\n  aliases       Manage model aliases\n  chat          Hold an ongoing chat with a model.\n  collections   View and manage collections of embeddings\n  embed         Embed text and store or return the result\n  embed-models  Manage available embedding models\n  embed-multi   Store embeddings for multiple strings at once in the...\n  fragments     Manage fragments that are stored in the database\n  install       Install packages from PyPI into the same environment as LLM\n  keys          Manage stored API keys for different models\n  logs          Tools for exploring logged prompts and responses\n  models        Manage available models\n  openai        Commands for working directly with the OpenAI API\n  plugins       List installed plugins\n  schemas       Manage stored schemas\n  similar       Return top N similar IDs from a collection using cosine...\n  templates     Manage stored prompt templates\n  tools         Manage tools that can be made available to LLMs\n  uninstall     Uninstall Python packages from the LLM environment\n```\n\n(help-prompt)=\n### llm prompt --help\n```\nUsage: llm prompt [OPTIONS] [PROMPT]\n\n  Execute a prompt\n\n  Documentation: https://llm.datasette.io/en/stable/usage.html\n\n  Examples:\n\n      llm 'Capital of France?'\n      llm 'Capital of France?' -m gpt-4o\n      llm 'Capital of France?' -s 'answer in Spanish'\n\n  Multi-modal models can be called with attachments like this:\n\n      llm 'Extract text from this image' -a image.jpg\n      llm 'Describe' -a https://static.simonwillison.net/static/2024/pelicans.jpg\n      cat image | llm 'describe image' -a -\n      # With an explicit mimetype:\n      cat image | llm 'describe image' --at - image/jpeg\n\n  The -x/--extract option returns just the content of the first ``` fenced code\n  block, if one is present. If none are present it returns the full response.\n\n      llm 'JavaScript function for reversing a string' -x\n\nOptions:\n  -s, --system TEXT               System prompt to use\n  -m, --model TEXT                Model to use\n  -d, --database FILE             Path to log database\n  -q, --query TEXT                Use first model matching these strings\n  -a, --attachment ATTACHMENT     Attachment path or URL or -\n  --at, --attachment-type <TEXT TEXT>...\n                                  Attachment with explicit mimetype,\n                                  --at image.jpg image/jpeg\n  -T, --tool TEXT                 Name of a tool to make available to the model\n  --functions TEXT                Python code block or file path defining\n                                  functions to register as tools\n  --td, --tools-debug             Show full details of tool executions\n  --ta, --tools-approve           Manually approve every tool execution\n  --cl, --chain-limit INTEGER     How many chained tool responses to allow,\n                                  default 5, set 0 for unlimited\n  -o, --option <TEXT TEXT>...     key/value options for the model\n  --schema TEXT                   JSON schema, filepath or ID\n  --schema-multi TEXT             JSON schema to use for multiple results\n  -f, --fragment TEXT             Fragment (alias, URL, hash or file path) to\n                                  add to the prompt\n  --sf, --system-fragment TEXT    Fragment to add to system prompt\n  -t, --template TEXT             Template to use\n  -p, --param <TEXT TEXT>...      Parameters for template\n  --no-stream                     Do not stream output\n  -n, --no-log                    Don't log to database\n  --log                           Log prompt and response to the database\n  -c, --continue                  Continue the most recent conversation.\n  --cid, --conversation TEXT      Continue the conversation with the given ID.\n  --key TEXT                      API key to use\n  --save TEXT                     Save prompt with this template name\n  --async                         Run prompt asynchronously\n  -u, --usage                     Show token usage\n  -x, --extract                   Extract first fenced code block\n  --xl, --extract-last            Extract last fenced code block\n  -h, --help                      Show this message and exit.\n```\n\n(help-chat)=\n### llm chat --help\n```\nUsage: llm chat [OPTIONS]\n\n  Hold an ongoing chat with a model.\n\nOptions:\n  -s, --system TEXT             System prompt to use\n  -m, --model TEXT              Model to use\n  -c, --continue                Continue the most recent conversation.\n  --cid, --conversation TEXT    Continue the conversation with the given ID.\n  -f, --fragment TEXT           Fragment (alias, URL, hash or file path) to add\n                                to the prompt\n  --sf, --system-fragment TEXT  Fragment to add to system prompt\n  -t, --template TEXT           Template to use\n  -p, --param <TEXT TEXT>...    Parameters for template\n  -o, --option <TEXT TEXT>...   key/value options for the model\n  -d, --database FILE           Path to log database\n  --no-stream                   Do not stream output\n  --key TEXT                    API key to use\n  -T, --tool TEXT               Name of a tool to make available to the model\n  --functions TEXT              Python code block or file path defining\n                                functions to register as tools\n  --td, --tools-debug           Show full details of tool executions\n  --ta, --tools-approve         Manually approve every tool execution\n  --cl, --chain-limit INTEGER   How many chained tool responses to allow,\n                                default 5, set 0 for unlimited\n  -h, --help                    Show this message and exit.\n```\n\n(help-keys)=\n### llm keys --help\n```\nUsage: llm keys [OPTIONS] COMMAND [ARGS]...\n\n  Manage stored API keys for different models\n\nOptions:\n  -h, --help  Show this message and exit.\n\nCommands:\n  list*  List names of all stored keys\n  get    Return the value of a stored key\n  path   Output the path to the keys.json file\n  set    Save a key in the keys.json file\n```\n\n(help-keys-list)=\n#### llm keys list --help\n```\nUsage: llm keys list [OPTIONS]\n\n  List names of all stored keys\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-keys-path)=\n#### llm keys path --help\n```\nUsage: llm keys path [OPTIONS]\n\n  Output the path to the keys.json file\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-keys-get)=\n#### llm keys get --help\n```\nUsage: llm keys get [OPTIONS] NAME\n\n  Return the value of a stored key\n\n  Example usage:\n\n      export OPENAI_API_KEY=$(llm keys get openai)\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-keys-set)=\n#### llm keys set --help\n```\nUsage: llm keys set [OPTIONS] NAME\n\n  Save a key in the keys.json file\n\n  Example usage:\n\n      $ llm keys set openai\n      Enter key: ...\n\nOptions:\n  --value TEXT  Value to set\n  -h, --help    Show this message and exit.\n```\n\n(help-logs)=\n### llm logs --help\n```\nUsage: llm logs [OPTIONS] COMMAND [ARGS]...\n\n  Tools for exploring logged prompts and responses\n\nOptions:\n  -h, --help  Show this message and exit.\n\nCommands:\n  list*   Show logged prompts and their responses\n  backup  Backup your logs database to this file\n  off     Turn off logging for all prompts\n  on      Turn on logging for all prompts\n  path    Output the path to the logs.db file\n  status  Show current status of database logging\n```\n\n(help-logs-path)=\n#### llm logs path --help\n```\nUsage: llm logs path [OPTIONS]\n\n  Output the path to the logs.db file\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-logs-status)=\n#### llm logs status --help\n```\nUsage: llm logs status [OPTIONS]\n\n  Show current status of database logging\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-logs-backup)=\n#### llm logs backup --help\n```\nUsage: llm logs backup [OPTIONS] PATH\n\n  Backup your logs database to this file\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-logs-on)=\n#### llm logs on --help\n```\nUsage: llm logs on [OPTIONS]\n\n  Turn on logging for all prompts\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-logs-off)=\n#### llm logs off --help\n```\nUsage: llm logs off [OPTIONS]\n\n  Turn off logging for all prompts\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-logs-list)=\n#### llm logs list --help\n```\nUsage: llm logs list [OPTIONS]\n\n  Show logged prompts and their responses\n\nOptions:\n  -n, --count INTEGER         Number of entries to show - defaults to 3, use 0\n                              for all\n  -d, --database FILE         Path to log database\n  -m, --model TEXT            Filter by model or model alias\n  -q, --query TEXT            Search for logs matching this string\n  -f, --fragment TEXT         Filter for prompts using these fragments\n  -T, --tool TEXT             Filter for prompts with results from these tools\n  --tools                     Filter for prompts with results from any tools\n  --schema TEXT               JSON schema, filepath or ID\n  --schema-multi TEXT         JSON schema used for multiple results\n  -l, --latest                Return latest results matching search query\n  --data                      Output newline-delimited JSON data for schema\n  --data-array                Output JSON array of data for schema\n  --data-key TEXT             Return JSON objects from array in this key\n  --data-ids                  Attach corresponding IDs to JSON objects\n  -t, --truncate              Truncate long strings in output\n  -s, --short                 Shorter YAML output with truncated prompts\n  -u, --usage                 Include token usage\n  -r, --response              Just output the last response\n  -x, --extract               Extract first fenced code block\n  --xl, --extract-last        Extract last fenced code block\n  -c, --current               Show logs from the current conversation\n  --cid, --conversation TEXT  Show logs for this conversation ID\n  --id-gt TEXT                Return responses with ID > this\n  --id-gte TEXT               Return responses with ID >= this\n  --json                      Output logs as JSON\n  -e, --expand                Expand fragments to show their content\n  -h, --help                  Show this message and exit.\n```\n\n(help-models)=\n### llm models --help\n```\nUsage: llm models [OPTIONS] COMMAND [ARGS]...\n\n  Manage available models\n\nOptions:\n  -h, --help  Show this message and exit.\n\nCommands:\n  list*    List available models\n  default  Show or set the default model\n  options  Manage default options for models\n```\n\n(help-models-list)=\n#### llm models list --help\n```\nUsage: llm models list [OPTIONS]\n\n  List available models\n\nOptions:\n  --options         Show options for each model, if available\n  --async           List async models\n  --schemas         List models that support schemas\n  --tools           List models that support tools\n  -q, --query TEXT  Search for models matching these strings\n  -m, --model TEXT  Specific model IDs\n  -h, --help        Show this message and exit.\n```\n\n(help-models-default)=\n#### llm models default --help\n```\nUsage: llm models default [OPTIONS] [MODEL]\n\n  Show or set the default model\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-models-options)=\n#### llm models options --help\n```\nUsage: llm models options [OPTIONS] COMMAND [ARGS]...\n\n  Manage default options for models\n\nOptions:\n  -h, --help  Show this message and exit.\n\nCommands:\n  list*  List default options for all models\n  clear  Clear default option(s) for a model\n  set    Set a default option for a model\n  show   List default options set for a specific model\n```\n\n(help-models-options-list)=\n##### llm models options list --help\n```\nUsage: llm models options list [OPTIONS]\n\n  List default options for all models\n\n  Example usage:\n\n      llm models options list\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-models-options-show)=\n##### llm models options show --help\n```\nUsage: llm models options show [OPTIONS] MODEL\n\n  List default options set for a specific model\n\n  Example usage:\n\n      llm models options show gpt-4o\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-models-options-set)=\n##### llm models options set --help\n```\nUsage: llm models options set [OPTIONS] MODEL KEY VALUE\n\n  Set a default option for a model\n\n  Example usage:\n\n      llm models options set gpt-4o temperature 0.5\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-models-options-clear)=\n##### llm models options clear --help\n```\nUsage: llm models options clear [OPTIONS] MODEL [KEY]\n\n  Clear default option(s) for a model\n\n  Example usage:\n\n      llm models options clear gpt-4o\n      # Or for a single option\n      llm models options clear gpt-4o temperature\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-templates)=\n### llm templates --help\n```\nUsage: llm templates [OPTIONS] COMMAND [ARGS]...\n\n  Manage stored prompt templates\n\nOptions:\n  -h, --help  Show this message and exit.\n\nCommands:\n  list*    List available prompt templates\n  edit     Edit the specified prompt template using the default $EDITOR\n  loaders  Show template loaders registered by plugins\n  path     Output the path to the templates directory\n  show     Show the specified prompt template\n```\n\n(help-templates-list)=\n#### llm templates list --help\n```\nUsage: llm templates list [OPTIONS]\n\n  List available prompt templates\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-templates-show)=\n#### llm templates show --help\n```\nUsage: llm templates show [OPTIONS] NAME\n\n  Show the specified prompt template\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-templates-edit)=\n#### llm templates edit --help\n```\nUsage: llm templates edit [OPTIONS] NAME\n\n  Edit the specified prompt template using the default $EDITOR\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-templates-path)=\n#### llm templates path --help\n```\nUsage: llm templates path [OPTIONS]\n\n  Output the path to the templates directory\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-templates-loaders)=\n#### llm templates loaders --help\n```\nUsage: llm templates loaders [OPTIONS]\n\n  Show template loaders registered by plugins\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-schemas)=\n### llm schemas --help\n```\nUsage: llm schemas [OPTIONS] COMMAND [ARGS]...\n\n  Manage stored schemas\n\nOptions:\n  -h, --help  Show this message and exit.\n\nCommands:\n  list*  List stored schemas\n  dsl    Convert LLM's schema DSL to a JSON schema\n  show   Show a stored schema\n```\n\n(help-schemas-list)=\n#### llm schemas list --help\n```\nUsage: llm schemas list [OPTIONS]\n\n  List stored schemas\n\nOptions:\n  -d, --database FILE  Path to log database\n  -q, --query TEXT     Search for schemas matching this string\n  --full               Output full schema contents\n  --json               Output as JSON\n  --nl                 Output as newline-delimited JSON\n  -h, --help           Show this message and exit.\n```\n\n(help-schemas-show)=\n#### llm schemas show --help\n```\nUsage: llm schemas show [OPTIONS] SCHEMA_ID\n\n  Show a stored schema\n\nOptions:\n  -d, --database FILE  Path to log database\n  -h, --help           Show this message and exit.\n```\n\n(help-schemas-dsl)=\n#### llm schemas dsl --help\n```\nUsage: llm schemas dsl [OPTIONS] INPUT\n\n  Convert LLM's schema DSL to a JSON schema\n\n      llm schema dsl 'name, age int, bio: their bio'\n\nOptions:\n  --multi     Wrap in an array\n  -h, --help  Show this message and exit.\n```\n\n(help-tools)=\n### llm tools --help\n```\nUsage: llm tools [OPTIONS] COMMAND [ARGS]...\n\n  Manage tools that can be made available to LLMs\n\nOptions:\n  -h, --help  Show this message and exit.\n\nCommands:\n  list*  List available tools that have been provided by plugins\n```\n\n(help-tools-list)=\n#### llm tools list --help\n```\nUsage: llm tools list [OPTIONS] [TOOL_DEFS]...\n\n  List available tools that have been provided by plugins\n\nOptions:\n  --json            Output as JSON\n  --functions TEXT  Python code block or file path defining functions to\n                    register as tools\n  -h, --help        Show this message and exit.\n```\n\n(help-aliases)=\n### llm aliases --help\n```\nUsage: llm aliases [OPTIONS] COMMAND [ARGS]...\n\n  Manage model aliases\n\nOptions:\n  -h, --help  Show this message and exit.\n\nCommands:\n  list*   List current aliases\n  path    Output the path to the aliases.json file\n  remove  Remove an alias\n  set     Set an alias for a model\n```\n\n(help-aliases-list)=\n#### llm aliases list --help\n```\nUsage: llm aliases list [OPTIONS]\n\n  List current aliases\n\nOptions:\n  --json      Output as JSON\n  -h, --help  Show this message and exit.\n```\n\n(help-aliases-set)=\n#### llm aliases set --help\n```\nUsage: llm aliases set [OPTIONS] ALIAS [MODEL_ID]\n\n  Set an alias for a model\n\n  Example usage:\n\n      llm aliases set mini gpt-4o-mini\n\n  Alternatively you can omit the model ID and specify one or more -q options.\n  The first model matching all of those query strings will be used.\n\n      llm aliases set mini -q 4o -q mini\n\nOptions:\n  -q, --query TEXT  Set alias for model matching these strings\n  -h, --help        Show this message and exit.\n```\n\n(help-aliases-remove)=\n#### llm aliases remove --help\n```\nUsage: llm aliases remove [OPTIONS] ALIAS\n\n  Remove an alias\n\n  Example usage:\n\n      $ llm aliases remove turbo\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-aliases-path)=\n#### llm aliases path --help\n```\nUsage: llm aliases path [OPTIONS]\n\n  Output the path to the aliases.json file\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-fragments)=\n### llm fragments --help\n```\nUsage: llm fragments [OPTIONS] COMMAND [ARGS]...\n\n  Manage fragments that are stored in the database\n\n  Fragments are reusable snippets of text that are shared across multiple\n  prompts.\n\nOptions:\n  -h, --help  Show this message and exit.\n\nCommands:\n  list*    List current fragments\n  loaders  Show fragment loaders registered by plugins\n  remove   Remove a fragment alias\n  set      Set an alias for a fragment\n  show     Display the fragment stored under an alias or hash\n```\n\n(help-fragments-list)=\n#### llm fragments list --help\n```\nUsage: llm fragments list [OPTIONS]\n\n  List current fragments\n\nOptions:\n  -q, --query TEXT  Search for fragments matching these strings\n  --aliases         Show only fragments with aliases\n  --json            Output as JSON\n  -h, --help        Show this message and exit.\n```\n\n(help-fragments-set)=\n#### llm fragments set --help\n```\nUsage: llm fragments set [OPTIONS] ALIAS FRAGMENT\n\n  Set an alias for a fragment\n\n  Accepts an alias and a file path, URL, hash or '-' for stdin\n\n  Example usage:\n\n      llm fragments set mydocs ./docs.md\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-fragments-show)=\n#### llm fragments show --help\n```\nUsage: llm fragments show [OPTIONS] ALIAS_OR_HASH\n\n  Display the fragment stored under an alias or hash\n\n      llm fragments show mydocs\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-fragments-remove)=\n#### llm fragments remove --help\n```\nUsage: llm fragments remove [OPTIONS] ALIAS\n\n  Remove a fragment alias\n\n  Example usage:\n\n      llm fragments remove docs\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-fragments-loaders)=\n#### llm fragments loaders --help\n```\nUsage: llm fragments loaders [OPTIONS]\n\n  Show fragment loaders registered by plugins\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-plugins)=\n### llm plugins --help\n```\nUsage: llm plugins [OPTIONS]\n\n  List installed plugins\n\nOptions:\n  --all        Include built-in default plugins\n  --hook TEXT  Filter for plugins that implement this hook\n  -h, --help   Show this message and exit.\n```\n\n(help-install)=\n### llm install --help\n```\nUsage: llm install [OPTIONS] [PACKAGES]...\n\n  Install packages from PyPI into the same environment as LLM\n\nOptions:\n  -U, --upgrade        Upgrade packages to latest version\n  -e, --editable TEXT  Install a project in editable mode from this path\n  --force-reinstall    Reinstall all packages even if they are already up-to-\n                       date\n  --no-cache-dir       Disable the cache\n  --pre                Include pre-release and development versions\n  -h, --help           Show this message and exit.\n```\n\n(help-uninstall)=\n### llm uninstall --help\n```\nUsage: llm uninstall [OPTIONS] PACKAGES...\n\n  Uninstall Python packages from the LLM environment\n\nOptions:\n  -y, --yes   Don't ask for confirmation\n  -h, --help  Show this message and exit.\n```\n\n(help-embed)=\n### llm embed --help\n```\nUsage: llm embed [OPTIONS] [COLLECTION] [ID]\n\n  Embed text and store or return the result\n\nOptions:\n  -i, --input PATH                File to embed\n  -m, --model TEXT                Embedding model to use\n  --store                         Store the text itself in the database\n  -d, --database FILE\n  -c, --content TEXT              Content to embed\n  --binary                        Treat input as binary data\n  --metadata TEXT                 JSON object metadata to store\n  -f, --format [json|blob|base64|hex]\n                                  Output format\n  -h, --help                      Show this message and exit.\n```\n\n(help-embed-multi)=\n### llm embed-multi --help\n```\nUsage: llm embed-multi [OPTIONS] COLLECTION [INPUT_PATH]\n\n  Store embeddings for multiple strings at once in the specified collection.\n\n  Input data can come from one of three sources:\n\n  1. A CSV, TSV, JSON or JSONL file:\n     - CSV/TSV: First column is ID, remaining columns concatenated as content\n     - JSON: Array of objects with \"id\" field and content fields\n     - JSONL: Newline-delimited JSON objects\n\n     Examples:\n       llm embed-multi docs input.csv\n       cat data.json | llm embed-multi docs -\n       llm embed-multi docs input.json --format json\n\n  2. A SQL query against a SQLite database:\n     - First column returned is used as ID\n     - Other columns concatenated to form content\n\n     Examples:\n       llm embed-multi docs --sql \"SELECT id, title, body FROM posts\"\n       llm embed-multi docs --attach blog blog.db --sql \"SELECT id, content FROM blog.posts\"\n\n  3. Files in directories matching glob patterns:\n     - Each file becomes one embedding\n     - Relative file paths become IDs\n\n     Examples:\n       llm embed-multi docs --files docs '**/*.md'\n       llm embed-multi images --files photos '*.jpg' --binary\n       llm embed-multi texts --files texts '*.txt' --encoding utf-8 --encoding latin-1\n\nOptions:\n  --format [json|csv|tsv|nl]   Format of input file - defaults to auto-detect\n  --files <DIRECTORY TEXT>...  Embed files in this directory - specify directory\n                               and glob pattern\n  --encoding TEXT              Encodings to try when reading --files\n  --binary                     Treat --files as binary data\n  --sql TEXT                   Read input using this SQL query\n  --attach <TEXT FILE>...      Additional databases to attach - specify alias\n                               and file path\n  --batch-size INTEGER         Batch size to use when running embeddings\n  --prefix TEXT                Prefix to add to the IDs\n  -m, --model TEXT             Embedding model to use\n  --prepend TEXT               Prepend this string to all content before\n                               embedding\n  --store                      Store the text itself in the database\n  -d, --database FILE\n  -h, --help                   Show this message and exit.\n```\n\n(help-similar)=\n### llm similar --help\n```\nUsage: llm similar [OPTIONS] COLLECTION [ID]\n\n  Return top N similar IDs from a collection using cosine similarity.\n\n  Example usage:\n\n      llm similar my-collection -c \"I like cats\"\n\n  Or to find content similar to a specific stored ID:\n\n      llm similar my-collection 1234\n\nOptions:\n  -i, --input PATH      File to embed for comparison\n  -c, --content TEXT    Content to embed for comparison\n  --binary              Treat input as binary data\n  -n, --number INTEGER  Number of results to return\n  -p, --plain           Output in plain text format\n  -d, --database FILE\n  --prefix TEXT         Just IDs with this prefix\n  -h, --help            Show this message and exit.\n```\n\n(help-embed-models)=\n### llm embed-models --help\n```\nUsage: llm embed-models [OPTIONS] COMMAND [ARGS]...\n\n  Manage available embedding models\n\nOptions:\n  -h, --help  Show this message and exit.\n\nCommands:\n  list*    List available embedding models\n  default  Show or set the default embedding model\n```\n\n(help-embed-models-list)=\n#### llm embed-models list --help\n```\nUsage: llm embed-models list [OPTIONS]\n\n  List available embedding models\n\nOptions:\n  -q, --query TEXT  Search for embedding models matching these strings\n  -h, --help        Show this message and exit.\n```\n\n(help-embed-models-default)=\n#### llm embed-models default --help\n```\nUsage: llm embed-models default [OPTIONS] [MODEL]\n\n  Show or set the default embedding model\n\nOptions:\n  --remove-default  Reset to specifying no default model\n  -h, --help        Show this message and exit.\n```\n\n(help-collections)=\n### llm collections --help\n```\nUsage: llm collections [OPTIONS] COMMAND [ARGS]...\n\n  View and manage collections of embeddings\n\nOptions:\n  -h, --help  Show this message and exit.\n\nCommands:\n  list*   View a list of collections\n  delete  Delete the specified collection\n  path    Output the path to the embeddings database\n```\n\n(help-collections-path)=\n#### llm collections path --help\n```\nUsage: llm collections path [OPTIONS]\n\n  Output the path to the embeddings database\n\nOptions:\n  -h, --help  Show this message and exit.\n```\n\n(help-collections-list)=\n#### llm collections list --help\n```\nUsage: llm collections list [OPTIONS]\n\n  View a list of collections\n\nOptions:\n  -d, --database FILE  Path to embeddings database\n  --json               Output as JSON\n  -h, --help           Show this message and exit.\n```\n\n(help-collections-delete)=\n#### llm collections delete --help\n```\nUsage: llm collections delete [OPTIONS] COLLECTION\n\n  Delete the specified collection\n\n  Example usage:\n\n      llm collections delete my-collection\n\nOptions:\n  -d, --database FILE  Path to embeddings database\n  -h, --help           Show this message and exit.\n```\n\n(help-openai)=\n### llm openai --help\n```\nUsage: llm openai [OPTIONS] COMMAND [ARGS]...\n\n  Commands for working directly with the OpenAI API\n\nOptions:\n  -h, --help  Show this message and exit.\n\nCommands:\n  models  List models available to you from the OpenAI API\n```\n\n(help-openai-models)=\n#### llm openai models --help\n```\nUsage: llm openai models [OPTIONS]\n\n  List models available to you from the OpenAI API\n\nOptions:\n  --json      Output as JSON\n  --key TEXT  OpenAI API key\n  -h, --help  Show this message and exit.\n```\n<!-- [[[end]]] -->"
  },
  {
    "path": "docs/index.md",
    "content": "# LLM\n\n[![GitHub repo](https://img.shields.io/badge/github-repo-green)](https://github.com/simonw/llm)\n[![PyPI](https://img.shields.io/pypi/v/llm.svg)](https://pypi.org/project/llm/)\n[![Changelog](https://img.shields.io/github/v/release/simonw/llm?include_prereleases&label=changelog)](https://llm.datasette.io/en/stable/changelog.html)\n[![Tests](https://github.com/simonw/llm/workflows/Test/badge.svg)](https://github.com/simonw/llm/actions?query=workflow%3ATest)\n[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/llm/blob/main/LICENSE)\n[![Discord](https://img.shields.io/discord/823971286308356157?label=discord)](https://datasette.io/discord-llm)\n[![Homebrew](https://img.shields.io/homebrew/installs/dy/llm?color=yellow&label=homebrew&logo=homebrew)](https://formulae.brew.sh/formula/llm)\n\nA 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.\n\nWatch **[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/).\n\nWith LLM you can:\n- {ref}`Run prompts from the command-line <usage-executing-prompts>`\n- {ref}`Store prompts and responses in SQLite <logging>`\n- {ref}`Generate and store embeddings <embeddings>`\n- {ref}`Extract structured content from text and images <schemas>`\n- {ref}`Grant models the ability to execute tools <tools>`\n- ... and much, much more\n\n## Quick start\n\nFirst, install LLM using `pip` or Homebrew or `pipx` or `uv`:\n\n```bash\npip install llm\n```\nOr with Homebrew (see {ref}`warning note <homebrew-warning>`):\n```bash\nbrew install llm\n```\nOr with [pipx](https://pypa.github.io/pipx/):\n```bash\npipx install llm\n```\nOr with [uv](https://docs.astral.sh/uv/guides/tools/)\n```bash\nuv tool install llm\n```\nIf you have an [OpenAI API key](https://platform.openai.com/api-keys) key you can run this:\n```bash\n# Paste your OpenAI API key into this\nllm keys set openai\n\n# Run a prompt (with the default gpt-4o-mini model)\nllm \"Ten fun names for a pet pelican\"\n\n# Extract text from an image\nllm \"extract text\" -a scanned-document.jpg\n\n# Use a system prompt against a file\ncat myfile.py | llm -s \"Explain this code\"\n```\nRun prompts against [Gemini](https://aistudio.google.com/apikey) or [Anthropic](https://console.anthropic.com/) with their respective plugins:\n```bash\nllm install llm-gemini\nllm keys set gemini\n# Paste Gemini API key here\nllm -m gemini-2.0-flash 'Tell me fun facts about Mountain View'\n\nllm install llm-anthropic\nllm keys set anthropic\n# Paste Anthropic API key here\nllm -m claude-4-opus 'Impress me with wild facts about turnips'\n```\nYou can also {ref}`install a plugin <installing-plugins>` to access models that can run on your local device. If you use [Ollama](https://ollama.com/):\n```bash\n# Install the plugin\nllm install llm-ollama\n\n# Download and run a prompt against the Orca Mini 7B model\nollama pull llama3.2:latest\nllm -m llama3.2:latest 'What is the capital of France?'\n```\nTo start {ref}`an interactive chat <usage-chat>` with a model, use `llm chat`:\n```bash\nllm chat -m gpt-4.1\n```\n```\nChatting with gpt-4.1\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 <my_fragment> [<another_fragment> ...]' to insert one or more fragments\n> Tell me a joke about a pelican\nWhy don't pelicans like to tip waiters?\n\nBecause they always have a big bill!\n```\n\nMore background on this project:\n\n- [llm, ttok and strip-tags—CLI tools for working with ChatGPT and other LLMs](https://simonwillison.net/2023/May/18/cli-tools-for-llms/)\n- [The LLM CLI tool now supports self-hosted language models via plugins](https://simonwillison.net/2023/Jul/12/llm/)\n- [LLM now provides tools for working with embeddings](https://simonwillison.net/2023/Sep/4/llm-embeddings/)\n- [Build an image search engine with llm-clip, chat with models with llm chat](https://simonwillison.net/2023/Sep/12/llm-clip-and-chat/)\n- [You can now run prompts against images, audio and video in your terminal using LLM](https://simonwillison.net/2024/Oct/29/llm-multi-modal/)\n- [Structured data extraction from unstructured content using LLM schemas](https://simonwillison.net/2025/Feb/28/llm-schemas/)\n- [Long context support in LLM 0.24 using fragments and template plugins](https://simonwillison.net/2025/Apr/7/long-context-llm/)\n\nSee also [the llm tag](https://simonwillison.net/tags/llm/) on my blog.\n\n## Contents\n\n```{toctree}\n---\nmaxdepth: 3\n---\nsetup\nusage\nopenai-models\nother-models\ntools\nschemas\ntemplates\nfragments\naliases\nembeddings/index\nplugins/index\npython-api\nlogging\nrelated-tools\nhelp\ncontributing\n```\n```{toctree}\n---\nmaxdepth: 1\n---\nchangelog\n```\n"
  },
  {
    "path": "docs/logging.md",
    "content": "(logging)=\n# Logging to SQLite\n\n`llm` defaults to logging all prompts and responses to a SQLite database.\n\nYou can find the location of that database using the `llm logs path` command:\n\n```bash\nllm logs path\n```\nOn my Mac that outputs:\n```\n/Users/simon/Library/Application Support/io.datasette.llm/logs.db\n```\nThis will differ for other operating systems.\n\nTo avoid logging an individual prompt, pass `--no-log` or `-n` to the command:\n```bash\nllm 'Ten names for cheesecakes' -n\n```\n\nTo turn logging by default off:\n\n```bash\nllm logs off\n```\nIf you've turned off logging you can still log an individual prompt and response by adding `--log`:\n```bash\nllm 'Five ambitious names for a pet pterodactyl' --log\n```\nTo turn logging by default back on again:\n\n```bash\nllm logs on\n```\nTo see the status of the logs database, run this:\n```bash\nllm logs status\n```\nExample output:\n```\nLogging is ON for all prompts\nFound log database at /Users/simon/Library/Application Support/io.datasette.llm/logs.db\nNumber of conversations logged: 33\nNumber of responses logged:     48\nDatabase file size:             19.96MB\n```\n\n(logging-view)=\n\n## Viewing the logs\n\nYou can view the logs using the `llm logs` command:\n```bash\nllm logs\n```\nThis will output the three most recent logged items in Markdown format, showing both the prompt and the response formatted using Markdown.\n\nTo get back just the most recent prompt response as plain text, add `-r/--response`:\n\n```bash\nllm logs -r\n```\nUse `-x/--extract` to extract and return the first fenced code block from the selected log entries:\n\n```bash\nllm logs --extract\n```\nOr `--xl/--extract-last` for the last fenced code block:\n```bash\nllm logs --extract-last\n```\n\nAdd `--json` to get the log messages in JSON instead:\n\n```bash\nllm logs --json\n```\n\nAdd `-n 10` to see the ten most recent items:\n```bash\nllm logs -n 10\n```\nOr `-n 0` to see everything that has ever been logged:\n```bash\nllm logs -n 0\n```\nYou 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.\n```bash\nllm logs -n 1 -t --json\n```\nExample output:\n```json\n[\n  {\n    \"id\": \"01jm8ec74wxsdatyn5pq1fp0s5\",\n    \"model\": \"anthropic/claude-3-haiku-20240307\",\n    \"prompt\": \"hi\",\n    \"system\": null,\n    \"prompt_json\": null,\n    \"response\": \"Hello! How can I assist you today?\",\n    \"conversation_id\": \"01jm8ec74taftdgj2t4zra9z0j\",\n    \"duration_ms\": 560,\n    \"datetime_utc\": \"2025-02-16T22:34:30.374882+00:00\",\n    \"input_tokens\": 8,\n    \"output_tokens\": 12,\n    \"token_details\": null,\n    \"conversation_name\": \"hi\",\n    \"conversation_model\": \"anthropic/claude-3-haiku-20240307\",\n    \"attachments\": []\n  }\n]\n```\n\n(logging-short)=\n\n### -s/--short mode\n\nUse `-s/--short` to see a shortened YAML log with truncated prompts and no responses:\n```bash\nllm logs -n 2 --short\n```\nExample output:\n```yaml\n- model: deepseek-reasoner\n  datetime: '2025-02-02T06:39:53'\n  conversation: 01jk2pk05xq3d0vgk0202zrsg1\n  prompt:  H01 There are five huts. H02 The Scotsman lives in the purple hut. H03 The Welshman owns the parrot. H04 Kombucha is...\n- model: o3-mini\n  datetime: '2025-02-02T19:03:05'\n  conversation: 01jk40qkxetedzpf1zd8k9bgww\n  system: Formatting re-enabled. Write a detailed README with extensive usage examples.\n  prompt: <documents> <document index=\"1\"> <source>./Cargo.toml</source> <document_content> [package] name = \"py-limbo\" version...\n```\nInclude `-u/--usage` to include token usage information:\n\n```bash\nllm logs -n 1 --short --usage\n```\nExample output:\n```yaml\n- model: o3-mini\n  datetime: '2025-02-16T23:00:56'\n  conversation: 01jm8fxxnef92n1663c6ays8xt\n  system: Produce Python code that demonstrates every possible usage of yaml.dump\n    with all of the arguments it can take, especi...\n  prompt: <documents> <document index=\"1\"> <source>./setup.py</source> <document_content>\n    NAME = 'PyYAML' VERSION = '7.0.0.dev0...\n  usage:\n    input: 74793\n    output: 3550\n    details:\n      completion_tokens_details:\n        reasoning_tokens: 2240\n```\n\n(logging-conversation)=\n\n### Logs for a conversation\n\nTo view the logs for the most recent {ref}`conversation <usage-conversation>` you have had with a model, use `-c`:\n\n```bash\nllm logs -c\n```\nTo see logs for a specific conversation based on its ID, use `--cid ID` or `--conversation ID`:\n\n```bash\nllm logs --cid 01h82n0q9crqtnzmf13gkyxawg\n```\n\n(logging-search)=\n\n### Searching the logs\n\nYou can search the logs for a search term in the `prompt` or the `response` columns.\n```bash\nllm logs -q 'cheesecake'\n```\nThe most relevant results will be shown first.\n\nTo switch to sorting with most recent first, add `-l/--latest`. This can be combined with `-n` to limit the number of results shown:\n```bash\nllm logs -q 'cheesecake' -l -n 3\n```\n\n(logging-filter-id)=\n\n### Filtering past a specific ID\n\nIf you want to retrieve all of the logs that were recorded since a specific response ID you can do so using these options:\n\n- `--id-gt $ID` - every record with an ID greater than $ID\n- `--id-gte $ID` - every record with an ID greater than or equal to $ID\n\nIDs are always issued in ascending order by time, so this provides a useful way to see everything that has happened since a particular record.\n\nThis can be particularly useful when {ref}`working with schema data <schemas-logs>`, where you might want to access every record that you have created using a specific `--schema` but exclude records you have previously processed.\n\n(logging-filter-model)=\n\n### Filtering by model\n\nYou can filter to logs just for a specific model (or model alias) using `-m/--model`:\n```bash\nllm logs -m chatgpt\n```\n\n(logging-filter-fragments)=\n\n### Filtering by prompts that used specific fragments\n\nThe `-f/--fragment X` option will filter for just responses that were created using the specified {ref}`fragment <usage-fragments>` hash or alias or URL or filename.\n\nFragments 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:\n\n```bash\nllm logs -f https://llm.datasette.io/robots.txt --expand\n```\nYou can display just the content for a specific fragment hash ID (or alias) using the `llm fragments show` command:\n\n```bash\nllm fragments show 993fd38d898d2b59fd2d16c811da5bdac658faa34f0f4d411edde7c17ebb0680\n```\nIf you provide multiple fragments you will get back responses that used _all_ of those fragments.\n\n(logging-filter-tools)=\n\n### Filtering by prompts that used specific tools\n\nYou can filter for responses that used tools from specific fragments with the `--tool/-T` option:\n\n```bash\nllm logs -T simple_eval\n```\nThis 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.\n\nPass `--tool/-T` multiple times for responses that used all of the specified tools.\n\nUse the `llm logs --tools` flag to see _all_ responses that involved at least one tool result, including from `--functions`:\n\n```bash\nllm logs --tools\n```\n\n(logging-filter-schemas)=\n\n### Browsing data collected using schemas\n\nThe `--schema X` option can be used to view responses that used the specified schema, using any of the {ref}`ways to specify a schema <schemas-specify>`:\n\n```bash\nllm logs --schema 'name, age int, bio'\n```\n\nThis can be combined with `--data` and `--data-array` and `--data-key` to extract just the returned JSON data - consult the {ref}`schemas documentation <schemas-logs>` for details.\n\n(logging-datasette)=\n\n## Browsing logs using Datasette\n\nYou can also use [Datasette](https://datasette.io/) to browse your logs like this:\n\n```bash\ndatasette \"$(llm logs path)\"\n```\n\n(logging-backup)=\n\n## Backing up your database\n\nYou can backup your logs to another file using the `llm logs backup` command:\n\n```bash\nllm logs backup /tmp/backup.db\n```\nThis uses SQLite [VACUUM INTO](https://sqlite.org/lang_vacuum.html#vacuum_with_an_into_clause) under the hood.\n\n(logging-sql-schema)=\n\n## SQL schema\n\nHere's the SQL schema used by the `logs.db` database:\n\n<!-- [[[cog\nimport cog\nfrom llm.migrations import migrate\nimport sqlite_utils\nimport re\ndb = sqlite_utils.Database(memory=True)\nmigrate(db)\n\ndef cleanup_sql(sql):\n    first_line = sql.split('(')[0]\n    inner = re.search(r'\\((.*)\\)', sql, re.DOTALL).group(1)\n    columns = [l.strip() for l in inner.split(',')]\n    return first_line + '(\\n  ' + ',\\n  '.join(columns) + '\\n);'\n\ncog.out(\"```sql\\n\")\nfor table in (\n    \"conversations\", \"schemas\", \"responses\", \"responses_fts\", \"attachments\", \"prompt_attachments\",\n    \"fragments\", \"fragment_aliases\", \"prompt_fragments\", \"system_fragments\", \"tools\",\n    \"tool_responses\", \"tool_calls\", \"tool_results\", \"tool_instances\"\n):\n    schema = db[table].schema\n    cog.out(format(cleanup_sql(schema)))\n    cog.out(\"\\n\")\ncog.out(\"```\\n\")\n]]] -->\n```sql\nCREATE TABLE [conversations] (\n  [id] TEXT PRIMARY KEY,\n  [name] TEXT,\n  [model] TEXT\n);\nCREATE TABLE [schemas] (\n  [id] TEXT PRIMARY KEY,\n  [content] TEXT\n);\nCREATE TABLE \"responses\" (\n  [id] TEXT PRIMARY KEY,\n  [model] TEXT,\n  [prompt] TEXT,\n  [system] TEXT,\n  [prompt_json] TEXT,\n  [options_json] TEXT,\n  [response] TEXT,\n  [response_json] TEXT,\n  [conversation_id] TEXT REFERENCES [conversations]([id]),\n  [duration_ms] INTEGER,\n  [datetime_utc] TEXT,\n  [input_tokens] INTEGER,\n  [output_tokens] INTEGER,\n  [token_details] TEXT,\n  [schema_id] TEXT REFERENCES [schemas]([id]),\n  [resolved_model] TEXT\n);\nCREATE VIRTUAL TABLE [responses_fts] USING FTS5 (\n  [prompt],\n  [response],\n  content=[responses]\n);\nCREATE TABLE [attachments] (\n  [id] TEXT PRIMARY KEY,\n  [type] TEXT,\n  [path] TEXT,\n  [url] TEXT,\n  [content] BLOB\n);\nCREATE TABLE [prompt_attachments] (\n  [response_id] TEXT REFERENCES [responses]([id]),\n  [attachment_id] TEXT REFERENCES [attachments]([id]),\n  [order] INTEGER,\n  PRIMARY KEY ([response_id],\n  [attachment_id])\n);\nCREATE TABLE [fragments] (\n  [id] INTEGER PRIMARY KEY,\n  [hash] TEXT,\n  [content] TEXT,\n  [datetime_utc] TEXT,\n  [source] TEXT\n);\nCREATE TABLE [fragment_aliases] (\n  [alias] TEXT PRIMARY KEY,\n  [fragment_id] INTEGER REFERENCES [fragments]([id])\n);\nCREATE TABLE \"prompt_fragments\" (\n  [response_id] TEXT REFERENCES [responses]([id]),\n  [fragment_id] INTEGER REFERENCES [fragments]([id]),\n  [order] INTEGER,\n  PRIMARY KEY ([response_id],\n  [fragment_id],\n  [order])\n);\nCREATE TABLE \"system_fragments\" (\n  [response_id] TEXT REFERENCES [responses]([id]),\n  [fragment_id] INTEGER REFERENCES [fragments]([id]),\n  [order] INTEGER,\n  PRIMARY KEY ([response_id],\n  [fragment_id],\n  [order])\n);\nCREATE TABLE [tools] (\n  [id] INTEGER PRIMARY KEY,\n  [hash] TEXT,\n  [name] TEXT,\n  [description] TEXT,\n  [input_schema] TEXT,\n  [plugin] TEXT\n);\nCREATE TABLE [tool_responses] (\n  [tool_id] INTEGER REFERENCES [tools]([id]),\n  [response_id] TEXT REFERENCES [responses]([id]),\n  PRIMARY KEY ([tool_id],\n  [response_id])\n);\nCREATE TABLE [tool_calls] (\n  [id] INTEGER PRIMARY KEY,\n  [response_id] TEXT REFERENCES [responses]([id]),\n  [tool_id] INTEGER REFERENCES [tools]([id]),\n  [name] TEXT,\n  [arguments] TEXT,\n  [tool_call_id] TEXT\n);\nCREATE TABLE \"tool_results\" (\n  [id] INTEGER PRIMARY KEY,\n  [response_id] TEXT REFERENCES [responses]([id]),\n  [tool_id] INTEGER REFERENCES [tools]([id]),\n  [name] TEXT,\n  [output] TEXT,\n  [tool_call_id] TEXT,\n  [instance_id] INTEGER REFERENCES [tool_instances]([id]),\n  [exception] TEXT\n);\nCREATE TABLE [tool_instances] (\n  [id] INTEGER PRIMARY KEY,\n  [plugin] TEXT,\n  [name] TEXT,\n  [arguments] TEXT\n);\n```\n<!-- [[[end]]] -->\n`responses_fts` configures [SQLite full-text search](https://www.sqlite.org/fts5.html) against the `prompt` and `response` columns in the `responses` table.\n"
  },
  {
    "path": "docs/openai-models.md",
    "content": "(openai-models)=\n\n# OpenAI models\n\nLLM 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.\n\n(openai-models-configuration)=\n\n## Configuration\n\nAll 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.\n\nOnce you have created a key, configure LLM to use it by running:\n\n```bash\nllm keys set openai\n```\nThen paste in the API key.\n\n(openai-models-language)=\n\n## OpenAI language models\n\nRun `llm models` for a full list of available models. The OpenAI models supported by LLM are:\n\n<!-- [[[cog\nfrom click.testing import CliRunner\nfrom llm.cli import cli\nresult = CliRunner().invoke(cli, [\"models\", \"list\"])\nmodels = [line for line in result.output.split(\"\\n\") if line.startswith(\"OpenAI \")]\ncog.out(\"```\\n{}\\n```\".format(\"\\n\".join(models)))\n]]] -->\n```\nOpenAI Chat: gpt-4o (aliases: 4o)\nOpenAI Chat: chatgpt-4o-latest (aliases: chatgpt-4o)\nOpenAI Chat: gpt-4o-mini (aliases: 4o-mini)\nOpenAI Chat: gpt-4o-audio-preview\nOpenAI Chat: gpt-4o-audio-preview-2024-12-17\nOpenAI Chat: gpt-4o-audio-preview-2024-10-01\nOpenAI Chat: gpt-4o-mini-audio-preview\nOpenAI Chat: gpt-4o-mini-audio-preview-2024-12-17\nOpenAI Chat: gpt-4.1 (aliases: 4.1)\nOpenAI Chat: gpt-4.1-mini (aliases: 4.1-mini)\nOpenAI Chat: gpt-4.1-nano (aliases: 4.1-nano)\nOpenAI Chat: gpt-3.5-turbo (aliases: 3.5, chatgpt)\nOpenAI Chat: gpt-3.5-turbo-16k (aliases: chatgpt-16k, 3.5-16k)\nOpenAI Chat: gpt-4 (aliases: 4, gpt4)\nOpenAI Chat: gpt-4-32k (aliases: 4-32k)\nOpenAI Chat: gpt-4-1106-preview\nOpenAI Chat: gpt-4-0125-preview\nOpenAI Chat: gpt-4-turbo-2024-04-09\nOpenAI Chat: gpt-4-turbo (aliases: gpt-4-turbo-preview, 4-turbo, 4t)\nOpenAI Chat: gpt-4.5-preview-2025-02-27\nOpenAI Chat: gpt-4.5-preview (aliases: gpt-4.5)\nOpenAI Chat: o1\nOpenAI Chat: o1-2024-12-17\nOpenAI Chat: o1-preview\nOpenAI Chat: o1-mini\nOpenAI Chat: o3-mini\nOpenAI Chat: o3\nOpenAI Chat: o4-mini\nOpenAI Chat: gpt-5\nOpenAI Chat: gpt-5-mini\nOpenAI Chat: gpt-5-nano\nOpenAI Chat: gpt-5-2025-08-07\nOpenAI Chat: gpt-5-mini-2025-08-07\nOpenAI Chat: gpt-5-nano-2025-08-07\nOpenAI Chat: gpt-5.1\nOpenAI Chat: gpt-5.1-chat-latest\nOpenAI Chat: gpt-5.2\nOpenAI Chat: gpt-5.2-chat-latest\nOpenAI Chat: gpt-5.4\nOpenAI Chat: gpt-5.4-2026-03-05\nOpenAI Chat: gpt-5.4-mini\nOpenAI Chat: gpt-5.4-mini-2026-03-17\nOpenAI Chat: gpt-5.4-nano\nOpenAI Chat: gpt-5.4-nano-2026-03-17\nOpenAI Completion: gpt-3.5-turbo-instruct (aliases: 3.5-instruct, chatgpt-instruct)\n```\n<!-- [[[end]]] -->\n\nSee [the OpenAI models documentation](https://platform.openai.com/docs/models) for details of each of these.\n\n`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.\n\n[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.\n\n## Model features\n\nThe following features work with OpenAI models:\n\n- {ref}`System prompts <usage-system-prompts>` can be used to provide instructions that have a higher weight than the prompt itself.\n- {ref}`Attachments <usage-attachments>`. Many OpenAI models support image inputs - check which ones using `llm models --options`. Any model that accepts images can also accept PDFs.\n- {ref}`Schemas <usage-schemas>` can be used to influence the JSON structure of the model output.\n- {ref}`Model options <usage-model-options>` can be used to set parameters like `temperature`. Use `llm models --options` for a full list of supported options.\n\n(openai-models-embedding)=\n\n## OpenAI embedding models\n\nRun `llm embed-models` for a list of {ref}`embedding models <embeddings>`. The following OpenAI embedding models are supported by LLM:\n\n```\nada-002 (aliases: ada, oai)\n3-small\n3-large\n3-small-512\n3-large-256\n3-large-1024\n```\n\nThe `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.\n\nAn 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.\n\nOpenAI `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.\n\nThe vector size of the supported OpenAI embedding models are as follows:\n\n| Model | Size |\n| --- | --- |\n| ada-002 | 1536 |\n| 3-small | 1536 |\n| 3-large | 3072 |\n| 3-small-512 | 512 |\n| 3-large-256 | 256 |\n| 3-large-1024 | 1024 |\n\n(openai-completion-models)=\n\n## OpenAI completion models\n\nThe `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).\n\nCompletion 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.\n\n(openai-extra-models)=\n\n## Adding more OpenAI models\n\nOpenAI 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.\n\nRun this command to find the directory in which this file should be created:\n\n```bash\ndirname \"$(llm logs path)\"\n```\nOn my Mac laptop I get this:\n```\n~/Library/Application Support/io.datasette.llm\n```\nCreate a file in that directory called `extra-openai-models.yaml`.\n\nLet'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:\n\n```yaml\n- model_id: gpt-3.5-turbo-0613\n  model_name: gpt-3.5-turbo-0613\n  aliases: [\"0613\"]\n```\nThe `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.\n\nIf the model is a completion model (such as `gpt-3.5-turbo-instruct`) add `completion: true` to the configuration.\n\nIf the model supports structured extraction using json_schema, add `supports_schema: true` to the configuration.\n\nFor reasoning models like `o1` or `o3-mini` add `reasoning: true`.\n\nWith this configuration in place, the following command should run a prompt against the new model:\n\n```bash\nllm -m 0613 'What is the capital of France?'\n```\nRun `llm models` to confirm that the new model is now available:\n```bash\nllm models\n```\nExample output:\n```\nOpenAI Chat: gpt-3.5-turbo (aliases: 3.5, chatgpt)\nOpenAI Chat: gpt-3.5-turbo-16k (aliases: chatgpt-16k, 3.5-16k)\nOpenAI Chat: gpt-4 (aliases: 4, gpt4)\nOpenAI Chat: gpt-4-32k (aliases: 4-32k)\nOpenAI Chat: gpt-3.5-turbo-0613 (aliases: 0613)\n```\nRunning `llm logs -n 1` should confirm that the prompt and response has been correctly logged to the database.\n"
  },
  {
    "path": "docs/other-models.md",
    "content": "(other-models)=\n# Other models\n\nLLM supports OpenAI models by default. You can install {ref}`plugins <plugins>` to add support for other models. You can also add additional OpenAI-API-compatible models {ref}`using a configuration file <openai-extra-models>`.\n\n## Installing and using a local model\n\n{ref}`LLM plugins <plugins>` can provide local models that run on your machine.\n\nTo install **[llm-gpt4all](https://github.com/simonw/llm-gpt4all)**, providing 17 models from the [GPT4All](https://gpt4all.io/) project, run this:\n\n```bash\nllm install llm-gpt4all\n```\nRun `llm models` to see the expanded list of available models.\n\nTo run a prompt through one of the models from GPT4All specify it using `-m/--model`:\n```bash\nllm -m orca-mini-3b-gguf2-q4_0 'What is the capital of France?'\n```\nThe model will be downloaded and cached the first time you use it.\n\nCheck the {ref}`plugin directory <plugin-directory>` for the latest list of available plugins for other models.\n\n(openai-compatible-models)=\n\n## OpenAI-compatible models\n\nProjects 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.\n\nThe `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.\n\nThe `api_base` key can be used to point the OpenAI client library at a different API endpoint.\n\nTo 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:\n\n```yaml\n- model_id: orca-openai-compat\n  model_name: orca-mini-3b.ggmlv3\n  api_base: \"http://localhost:8080\"\n```\nIf the `api_base` is set, the existing configured `openai` API key will not be sent by default.\n\nYou can set `api_key_name` to the name of a key stored using the {ref}`api-keys` feature.\n\nOther keys you can use here:\n\n- `completion: true` for completion models that should use the `/completion` endpoint as opposed to `/completion/chat`\n- `supports_tools: true` for models that support tool calling\n- `can_stream: false` to disable streaming mode for models that cannot stream\n- `supports_schema: true` for models that support JSON structured schema output\n- `vision: true` for models that can accept images as input\n- `audio: true` for models that accept audio attachments\n\nHaving 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:\n\n```bash\nllm -m orca-openai-compat 'What is the capital of France?'\n```\nAnd confirm they were logged correctly with:\n```bash\nllm logs -n 1\n```\n\n### Extra HTTP headers\n\nSome 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:\n\n```yaml\n- model_id: claude\n  model_name: anthropic/claude-2\n  api_base: \"https://openrouter.ai/api/v1\"\n  api_key_name: openrouter\n  headers:\n    HTTP-Referer: \"https://llm.datasette.io/\"\n    X-Title: LLM\n```\n"
  },
  {
    "path": "docs/plugins/advanced-model-plugins.md",
    "content": "(advanced-model-plugins)=\n# Advanced model plugins\n\nThe {ref}`model plugin tutorial <tutorial-model-plugin>` covers the basics of developing a plugin that adds support for a new model. This document covers more advanced topics.\n\nFeatures to consider for your model plugin include:\n\n- {ref}`Accepting API keys <advanced-model-plugins-api-keys>` using the standard mechanism that incorporates `llm keys set`, environment variables and support for passing an explicit key to the model.\n- Including support for {ref}`Async models <advanced-model-plugins-async>` that can be used with Python's `asyncio` library.\n- Support for {ref}`structured output <advanced-model-plugins-schemas>` using JSON schemas.\n- Support for {ref}`tools <advanced-model-plugins-tools>`.\n- Handling {ref}`attachments <advanced-model-plugins-attachments>` (images, audio and more) for multi-modal models.\n- Tracking {ref}`token usage <advanced-model-plugins-usage>` for models that charge by the token.\n\n(advanced-model-plugins-lazy)=\n\n## Tip: lazily load expensive dependencies\n\nIf 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.\n\nInstead, 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`!\n\n(advanced-model-plugins-api-keys)=\n\n## Models that accept API keys\n\nModels that call out to API providers such as OpenAI, Anthropic or Google Gemini usually require an API key.\n\nLLM's API key management mechanism {ref}`is described here <api-keys>`.\n\nIf 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:\n\n```python\nimport llm\n\nclass HostedModel(llm.KeyModel):\n    needs_key = \"hosted\" # Required\n    key_env_var = \"HOSTED_API_KEY\" # Optional\n```\nThis 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.\n\nThen when you define your `execute()` method it should take an extra `key=` parameter like this:\n\n```python\n    def execute(self, prompt, stream, response, conversation, key=None):\n        # key= here will be the API key to use\n```\nLLM 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.\n\n(advanced-model-plugins-async)=\n\n## Async models\n\nPlugins 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.\n\nThe 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()`.\n\nThis example shows a subset of the OpenAI default plugin illustrating how this method might work:\n\n```python\nfrom typing import AsyncGenerator\nimport llm\n\nclass MyAsyncModel(llm.AsyncModel):\n    # This can duplicate the model_id of the sync model:\n    model_id = \"my-model-id\"\n\n    async def execute(\n        self, prompt, stream, response, conversation=None\n    ) -> AsyncGenerator[str, None]:\n        if stream:\n            completion = await client.chat.completions.create(\n                model=self.model_id,\n                messages=messages,\n                stream=True,\n            )\n            async for chunk in completion:\n                yield chunk.choices[0].delta.content\n        else:\n            completion = await client.chat.completions.create(\n                model=self.model_name or self.model_id,\n                messages=messages,\n                stream=False,\n            )\n            if completion.choices[0].message.content is not None:\n                yield completion.choices[0].message.content\n```\nIf your model takes an API key you should instead subclass `llm.AsyncKeyModel` and have a `key=` parameter on your `.execute()` method:\n\n```python\nclass MyAsyncModel(llm.AsyncKeyModel):\n    ...\n    async def execute(\n        self, prompt, stream, response, conversation=None, key=None\n    ) -> AsyncGenerator[str, None]:\n```\n\nThis async model instance should then be passed to the `register()` method in the `register_models()` plugin hook:\n\n```python\n@hookimpl\ndef register_models(register):\n    register(\n        MyModel(), MyAsyncModel(), aliases=(\"my-model-aliases\",)\n    )\n```\n\n(advanced-model-plugins-schemas)=\n\n## Supporting schemas\n\nIf your model supports {ref}`structured output <schemas>` against a defined JSON schema you can implement support by first adding `supports_schema = True` to the class:\n\n```python\nclass MyModel(llm.KeyModel):\n    ...\n    support_schema = True\n```\nAnd then adding code to your `.execute()` method that checks for `prompt.schema` and, if it is present, uses that to prompt the model.\n\n`prompt.schema` will always be a Python dictionary representing a JSON schema, even if the user passed in a Pydantic model class.\n\nCheck 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.\n\n(advanced-model-plugins-tools)=\n\n## Supporting tools\n\nAdding {ref}`tools support <tools>` involves several steps:\n\n1. Add `supports_tools = True` to your model class.\n2. If `prompt.tools` is populated, turn that list of `llm.Tool` objects into the correct format for your model.\n3. 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.\n4. If your prompt has a `prompt.tool_results` list, pass the information from those `llm.ToolResult` objects to your model.\n5. Include `prompt.tools` and `prompt.tool_results` and tool calls from `response.tool_calls_or_raise()` in the conversation history constructed by your plugin.\n6. 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.\n\nThis [commit to llm-gemini](https://github.com/simonw/llm-gemini/commit/a7f1096cfbb733018eb41c29028a8cc6160be298) implementing tools helps demonstrate what this looks like for a real plugin.\n\nHere are the relevant dataclasses:\n\n```{eval-rst}\n.. autoclass:: llm.Tool\n\n.. autoclass:: llm.ToolCall\n\n.. autoclass:: llm.ToolResult\n```\n\n\n(advanced-model-plugins-attachments)=\n\n## Attachments for multi-modal models\n\nModels 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.\n\nLLM calls these **attachments**. Models can specify the types of attachments they accept and then implement special code in the `.execute()` method to handle them.\n\nSee {ref}`the Python attachments documentation <python-api-attachments>` for details on using attachments in the Python API.\n\n### Specifying attachment types\n\nA `Model` subclass can list the types of attachments it accepts by defining a `attachment_types` class attribute:\n\n```python\nclass NewModel(llm.Model):\n    model_id = \"new-model\"\n    attachment_types = {\n        \"image/png\",\n        \"image/jpeg\",\n        \"image/webp\",\n        \"image/gif\",\n    }\n```\nThese 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.\n\n**Note:** MP3 files will have their attachment type detected as `audio/mpeg`, not `audio/mp3`.\n\nLLM will use the `attachment_types` attribute to validate that provided attachments should be accepted before passing them to the model.\n\n### Handling attachments\n\nThe `prompt` object passed to the `execute()` method will have an `attachments` attribute containing a list of `Attachment` objects provided by the user.\n\nAn `Attachment` instance has the following properties:\n\n- `url (str)`: The URL of the attachment, if it was provided as a URL\n- `path (str)`: The resolved file path of the attachment, if it was provided as a file\n- `type (str)`: The content type of the attachment, if it was provided\n- `content (bytes)`: The binary content of the attachment, if it was provided\n\nGenerally only one of `url`, `path` or `content` will be set.\n\nYou should usually access the type and the content through one of these methods:\n\n- `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\n- `attachment.content_bytes() -> bytes`: Returns the binary content, which it may need to read from a file or fetch from a URL\n- `attachment.base64_content() -> str`: Returns that content as a base64-encoded string\n\nA `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.\n\nNote 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`.\n\nHere's how the OpenAI plugin handles attachments, including the case where no `prompt.prompt` was provided:\n\n```python\nif not prompt.attachments:\n    messages.append({\"role\": \"user\", \"content\": prompt.prompt})\nelse:\n    attachment_message = []\n    if prompt.prompt:\n        attachment_message.append({\"type\": \"text\", \"text\": prompt.prompt})\n    for attachment in prompt.attachments:\n        attachment_message.append(_attachment(attachment))\n    messages.append({\"role\": \"user\", \"content\": attachment_message})\n\n\n# And the code for creating the attachment message\ndef _attachment(attachment):\n    url = attachment.url\n    base64_content = \"\"\n    if not url or attachment.resolve_type().startswith(\"audio/\"):\n        base64_content = attachment.base64_content()\n        url = f\"data:{attachment.resolve_type()};base64,{base64_content}\"\n    if attachment.resolve_type().startswith(\"image/\"):\n        return {\"type\": \"image_url\", \"image_url\": {\"url\": url}}\n    else:\n        format_ = \"wav\" if attachment.resolve_type() == \"audio/wav\" else \"mp3\"\n        return {\n            \"type\": \"input_audio\",\n            \"input_audio\": {\n                \"data\": base64_content,\n                \"format\": format_,\n            },\n        }\n```\nAs 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.\n\n### Attachments from previous conversations\n\nModels that implement the ability to continue a conversation can reconstruct the previous message JSON using the `response.attachments` attribute.\n\nHere's how the OpenAI plugin does that:\n\n```python\nfor prev_response in conversation.responses:\n    if prev_response.attachments:\n        attachment_message = []\n        if prev_response.prompt.prompt:\n            attachment_message.append(\n                {\"type\": \"text\", \"text\": prev_response.prompt.prompt}\n            )\n        for attachment in prev_response.attachments:\n            attachment_message.append(_attachment(attachment))\n        messages.append({\"role\": \"user\", \"content\": attachment_message})\n    else:\n        messages.append(\n            {\"role\": \"user\", \"content\": prev_response.prompt.prompt}\n        )\n    messages.append({\"role\": \"assistant\", \"content\": prev_response.text_or_raise()})\n```\nThe `response.text_or_raise()` method used there will return the text from the response or raise a `ValueError` exception if the response is an `AsyncResponse` instance that has not yet been fully resolved.\n\nThis is a slightly weird hack to work around the common need to share logic for building up the `messages` list across both sync and async models.\n\n(advanced-model-plugins-usage)=\n\n## Tracking token usage\n\nModels 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.\n\n`response` here is the response object that is passed to `.execute()` as an argument.\n\nCall ``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.\n\nThis example logs 15 input tokens, 340 output tokens and notes that 37 tokens were cached:\n\n```python\nresponse.set_usage(input=15, output=340, details={\"cached\": 37})\n```\n(advanced-model-plugins-resolved-model)=\n\n## Tracking resolved model names\n\nIn 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.\n\nIf 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:\n\n```bash\nresponse.set_resolved_model(resolved_model_id)\n```\nThis string will be recorded in the database and shown in the output of `llm logs` and `llm logs --json`.\n\n(tutorial-model-plugin-raise-errors)=\n\n## LLM_RAISE_ERRORS\n\nWhile 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.\n\nSet the `LLM_RAISE_ERRORS` environment variable to enable this behavior, then run `llm` like this:\n\n```bash\nLLM_RAISE_ERRORS=1 python -i -m llm ...\n```\nThe `-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:\n\n```python\nimport pdb; pdb.pm()\n```\n"
  },
  {
    "path": "docs/plugins/directory.md",
    "content": "(plugin-directory)=\n# Plugin directory\n\nThe following plugins are available for LLM. Here's {ref}`how to install them <installing-plugins>`.\n\n(plugin-directory-local-models)=\n## Local models\n\nThese plugins all help you run LLMs directly on your own computer:\n\n- **[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.\n- **[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.\n- **[llm-ollama](https://github.com/taketwo/llm-ollama)** adds support for local models run using [Ollama](https://ollama.ai/).\n- **[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).\n- **[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.\n- **[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).\n- **[llm-mpt30b](https://github.com/simonw/llm-mpt30b)** adds support for the [MPT-30B](https://huggingface.co/mosaicml/mpt-30b) local model.\n\n(plugin-directory-remote-apis)=\n## Remote APIs\n\nThese plugins can be used to interact with remotely hosted models via their API:\n\n- **[llm-mistral](https://github.com/simonw/llm-mistral)** adds support for [Mistral AI](https://mistral.ai/)'s language and embedding models.\n- **[llm-gemini](https://github.com/simonw/llm-gemini)** adds support for Google's [Gemini](https://ai.google.dev/docs) models.\n- **[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.\n- **[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.\n- **[llm-reka](https://github.com/simonw/llm-reka)** supports the [Reka](https://www.reka.ai/) family of models via their API.\n- **[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`.\n- **[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).\n- **[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).\n- **[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.\n- **[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.\n- **[llm-fireworks](https://github.com/simonw/llm-fireworks)** supports models hosted by [Fireworks AI](https://fireworks.ai/).\n- **[llm-openrouter](https://github.com/simonw/llm-openrouter)** provides access to models hosted on [OpenRouter](https://openrouter.ai/).\n- **[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/).\n- **[llm-bedrock](https://github.com/simonw/llm-bedrock)** adds support for Nova by Amazon via Amazon Bedrock.\n- **[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.\n- **[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.\n- **[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.\n- **[llm-deepseek](https://github.com/abrasumente233/llm-deepseek)** adds support for the [DeepSeek](https://deepseek.com)'s DeepSeek-Chat and DeepSeek-Coder models.\n- **[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.\n- **[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.\n\nIf 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.\n\n(plugin-directory-tools)=\n## Tools\n\nThe following plugins add new {ref}`tools <tools>` that can be used by models:\n\n- **[llm-tools-simpleeval](https://github.com/simonw/llm-tools-simpleeval)** implements simple expression support for things like mathematics.\n- **[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.\n- **[llm-tools-sqlite](https://github.com/simonw/llm-tools-sqlite)** can run read-only SQL queries against local SQLite databases.\n- **[llm-tools-datasette](https://github.com/simonw/llm-tools-datasette)** can run SQL queries against a remote [Datasette](https://datasette.io/) instance.\n- **[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/).\n- **[llm-tools-rag](https://github.com/daturkel/llm-tools-rag)** by Dan Turkel can perform searches over your LLM embedding collections for simple RAG.\n\n(plugin-directory-loaders)=\n## Fragments and template loaders\n\n{ref}`LLM 0.24 <v0_24>` introduced support for plugins that define `-f prefix:value` or `-t prefix:value` custom loaders for fragments and templates.\n\n- **[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'`.\n- **[llm-templates-github](https://github.com/simonw/llm-templates-github)** supports loading templates shared on GitHub, e.g. `llm -t gh:simonw/pelican-svg`.\n- **[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`.\n- **[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`.\n- **[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'`.\n- **[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?\"`.\n- **[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?\"`.\n- **[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\"`.\n- **[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`.\n\n(plugin-directory-embeddings)=\n## Embedding models\n\n{ref}`Embedding models <embeddings>` are models that can be used to generate and store embedding vectors for text.\n\n- **[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.\n- **[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.\n- **[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/).\n- **[llm-embed-onnx](https://github.com/simonw/llm-embed-onnx)** provides seven embedding models that can be executed using the ONNX model framework.\n\n(plugin-directory-commands)=\n## Extra commands\n\n- **[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 `<enter>` to execute or `ctrl+c` to cancel.\n- **[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 `<enter>` and it will go right back into your shell command line, so you can run it.\n- **[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`.\n- **[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.\n- **[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.\n\n(plugin-directory-fun)=\n## Just for fun\n\n- **[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).\n"
  },
  {
    "path": "docs/plugins/index.md",
    "content": "(plugins)=\n# Plugins\n\nLLM plugins can enhance LLM by making alternative Large Language Models available, either via API or by running the models locally on your machine.\n\nPlugins can also add new commands to the `llm` CLI tool.\n\nThe {ref}`plugin directory <plugin-directory>` lists available plugins that you can install and use.\n\n{ref}`tutorial-model-plugin` describes how to build a new plugin in detail.\n\n```{toctree}\n---\nmaxdepth: 3\n---\ninstalling-plugins\ndirectory\nplugin-hooks\ntutorial-model-plugin\nadvanced-model-plugins\nplugin-utilities\n```\n"
  },
  {
    "path": "docs/plugins/installing-plugins.md",
    "content": "(installing-plugins)=\n# Installing plugins\n\nPlugins must be installed in the same virtual environment as LLM itself.\n\nYou can find names of plugins to install in the {ref}`plugin directory <plugin-directory>`\n\nUse the `llm install` command (a thin wrapper around `pip install`) to install plugins in the correct environment:\n```bash\nllm install llm-gpt4all\n```\nPlugins can be uninstalled with `llm uninstall`:\n```bash\nllm uninstall llm-gpt4all -y\n```\nThe `-y` flag skips asking for confirmation.\n\nYou can see additional models that have been added by plugins by running:\n```bash\nllm models\n```\nOr add `--options` to include details of the options available for each model:\n```bash\nllm models --options\n```\nTo run a prompt against a newly installed model, pass its name as the `-m/--model` option:\n```bash\nllm -m orca-mini-3b-gguf2-q4_0 'What is the capital of France?'\n```\n\n## Listing installed plugins\n\nRun `llm plugins` to list installed plugins:\n\n```bash\nllm plugins\n```\n```json\n[\n  {\n    \"name\": \"llm-anthropic\",\n    \"hooks\": [\n      \"register_models\"\n    ],\n    \"version\": \"0.11\"\n  },\n  {\n    \"name\": \"llm-gguf\",\n    \"hooks\": [\n      \"register_commands\",\n      \"register_models\"\n    ],\n    \"version\": \"0.1a0\"\n  },\n  {\n    \"name\": \"llm-clip\",\n    \"hooks\": [\n      \"register_commands\",\n      \"register_embedding_models\"\n    ],\n    \"version\": \"0.1\"\n  },\n  {\n    \"name\": \"llm-cmd\",\n    \"hooks\": [\n      \"register_commands\"\n    ],\n    \"version\": \"0.2a0\"\n  },\n  {\n    \"name\": \"llm-gemini\",\n    \"hooks\": [\n      \"register_embedding_models\",\n      \"register_models\"\n    ],\n    \"version\": \"0.3\"\n  }\n]\n```\n\n(llm-load-plugins)=\n## Running with a subset of plugins\n\nBy default, LLM will load all plugins that are installed in the same virtual environment as LLM itself.\n\nYou can control the set of plugins that is loaded using the `LLM_LOAD_PLUGINS` environment variable.\n\nSet that to the empty string to disable all plugins:\n\n```bash\nLLM_LOAD_PLUGINS='' llm ...\n```\nOr to a comma-separated list of plugin names to load only those plugins:\n\n```bash\nLLM_LOAD_PLUGINS='llm-gpt4all,llm-cluster' llm ...\n```\nYou can use the `llm plugins` command to check that it is working correctly:\n```\nLLM_LOAD_PLUGINS='' llm plugins\n```\n"
  },
  {
    "path": "docs/plugins/llm-markov/llm_markov.py",
    "content": "import llm\nimport random\nimport time\nfrom typing import Optional\nfrom pydantic import field_validator, Field\n\n\n@llm.hookimpl\ndef register_models(register):\n    register(Markov())\n\n\ndef build_markov_table(text):\n    words = text.split()\n    transitions = {}\n    # Loop through all but the last word\n    for i in range(len(words) - 1):\n        word = words[i]\n        next_word = words[i + 1]\n        transitions.setdefault(word, []).append(next_word)\n    return transitions\n\n\ndef generate(transitions, length, start_word=None):\n    all_words = list(transitions.keys())\n    next_word = start_word or random.choice(all_words)\n    for i in range(length):\n        yield next_word\n        options = transitions.get(next_word) or all_words\n        next_word = random.choice(options)\n\n\nclass Markov(llm.Model):\n    model_id = \"markov\"\n    can_stream = True\n\n    class Options(llm.Options):\n        length: Optional[int] = Field(\n            description=\"Number of words to generate\", default=None\n        )\n        delay: Optional[float] = Field(\n            description=\"Seconds to delay between each token\", default=None\n        )\n\n        @field_validator(\"length\")\n        def validate_length(cls, length):\n            if length is None:\n                return None\n            if length < 2:\n                raise ValueError(\"length must be >= 2\")\n            return length\n\n        @field_validator(\"delay\")\n        def validate_delay(cls, delay):\n            if delay is None:\n                return None\n            if not 0 <= delay <= 10:\n                raise ValueError(\"delay must be between 0 and 10\")\n            return delay\n\n    def execute(self, prompt, stream, response, conversation):\n        text = prompt.prompt\n        transitions = build_markov_table(text)\n        length = prompt.options.length or 20\n        for word in generate(transitions, length):\n            yield word + \" \"\n            if prompt.options.delay:\n                time.sleep(prompt.options.delay)\n"
  },
  {
    "path": "docs/plugins/llm-markov/pyproject.toml",
    "content": "[project]\nname = \"llm-markov\"\nversion = \"0.1\"\n\n[project.entry-points.llm]\nmarkov = \"llm_markov\""
  },
  {
    "path": "docs/plugins/plugin-hooks.md",
    "content": "(plugin-hooks)=\n# Plugin hooks\n\nPlugins use **plugin hooks** to customize LLM's behavior. These hooks are powered by the [Pluggy plugin system](https://pluggy.readthedocs.io/).\n\nEach plugin can implement one or more hooks using the @hookimpl decorator against one of the hook function names described on this page.\n\nLLM imitates the Datasette plugin system. The [Datasette plugin documentation](https://docs.datasette.io/en/stable/writing_plugins.html) describes how plugins work.\n\n(plugin-hooks-register-commands)=\n## register_commands(cli)\n\nThis hook adds new commands to the `llm` CLI tool - for example `llm extra-command`.\n\nThis example plugin adds a new `hello-world` command that prints \"Hello world!\":\n\n```python\nfrom llm import hookimpl\nimport click\n\n@hookimpl\ndef register_commands(cli):\n    @cli.command(name=\"hello-world\")\n    def hello_world():\n        \"Print hello world\"\n        click.echo(\"Hello world!\")\n```\nThis new command will be added to `llm --help` and can be run using `llm hello-world`.\n\n(plugin-hooks-register-models)=\n## register_models(register)\n\nThis hook can be used to register one or more additional models.\n\n```python\nimport llm\n\n@llm.hookimpl\ndef register_models(register):\n    register(HelloWorld())\n\nclass HelloWorld(llm.Model):\n    model_id = \"helloworld\"\n\n    def execute(self, prompt, stream, response):\n        return [\"hello world\"]\n```\nIf your model includes an async version, you can register that too:\n\n```python\nclass AsyncHelloWorld(llm.AsyncModel):\n    model_id = \"helloworld\"\n\n    async def execute(self, prompt, stream, response):\n        return [\"hello world\"]\n\n@llm.hookimpl\ndef register_models(register):\n    register(HelloWorld(), AsyncHelloWorld(), aliases=(\"hw\",))\n```\nThis demonstrates how to register a model with both sync and async versions, and how to specify an alias for that model.\n\nThe {ref}`model plugin tutorial <tutorial-model-plugin>` describes how to use this hook in detail. Asynchronous models {ref}`are described here <advanced-model-plugins-async>`.\n\n(plugin-hooks-register-embedding-models)=\n## register_embedding_models(register)\n\nThis hook can be used to register one or more additional embedding models, as described in {ref}`embeddings-writing-plugins`.\n\n```python\nimport llm\n\n@llm.hookimpl\ndef register_embedding_models(register):\n    register(HelloWorld())\n\nclass HelloWorld(llm.EmbeddingModel):\n    model_id = \"helloworld\"\n\n    def embed_batch(self, items):\n        return [[1, 2, 3], [4, 5, 6]]\n```\n\n(plugin-hooks-register-tools)=\n## register_tools(register)\n\nThis hook can register one or more tool functions for use with LLM. See {ref}`the tools documentation <tools>` for more details.\n\nThis example registers two tools: `upper` and `count_character_in_word`.\n\n```python\nimport llm\n\ndef upper(text: str) -> str:\n    \"\"\"Convert text to uppercase.\"\"\"\n    return text.upper()\n\ndef count_char(text: str, character: str) -> int:\n    \"\"\"Count the number of occurrences of a character in a word.\"\"\"\n    return text.count(character)\n\n@llm.hookimpl\ndef register_tools(register):\n    register(upper)\n    # Here the name= argument is used to specify a different name for the tool:\n    register(count_char, name=\"count_character_in_word\")\n```\n\nTools can also be implemented as classes, as described in {ref}`Toolbox classes <python-api-toolbox>` in the Python API documentation.\n\nYou can register classes like the `Memory` example {ref}`from here <python-api-toolbox>` by passing the class (_not_ an instance of the class) to `register()`:\n\n```python\nimport llm\n\nclass Memory(llm.Toolbox):\n    # Copy implementation from the Python API documentation\n\n@llm.hookimpl\ndef register_tools(register):\n    register(Memory)\n```\nOnce installed, this tool can be used like so:\n\n```bash\nllm chat -T Memory\n```\nIf a tool name starts with a capital letter it is assumed to be a toolbox class, not a regular tool function.\n\nHere's an example session with the Memory tool:\n```\nChatting with gpt-4.1-mini\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 <my_fragment> [<another_fragment> ...]' to insert one or more fragments\n> Remember my name is Henry\n\nTool call: Memory_set({'key': 'user_name', 'value': 'Henry'})\n  null\n\nGot it, Henry! I'll remember your name. How can I assist you today?\n> what keys are there?\n\nTool call: Memory_keys({})\n  [\n    \"user_name\"\n  ]\n\nCurrently, there is one key stored: \"user_name\". Would you like to add or retrieve any information?\n> read it\n\nTool call: Memory_get({'key': 'user_name'})\n  Henry\n\nThe value stored under the key \"user_name\" is Henry. Is there anything else you'd like to do?\n> add Barrett to it\n\nTool call: Memory_append({'key': 'user_name', 'value': 'Barrett'})\n  null\n\nI have added \"Barrett\" to the key \"user_name\". If you want, I can now show you the updated value.\n> show value\n\nTool call: Memory_get({'key': 'user_name'})\n  Henry\n  Barrett\n\nThe value stored under the key \"user_name\" is now:\nHenry\nBarrett\n\nIs there anything else you would like to do?\n```\n\n(plugin-hooks-register-template-loaders)=\n## register_template_loaders(register)\n\nPlugins can register new {ref}`template loaders <prompt-templates-loaders>` using the `register_template_loaders` hook.\n\nTemplate 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.\n\nThis example plugin registers `my-prefix` as a new template loader. Once installed it can be used like this:\n\n```bash\nllm -t my-prefix:my-template\n```\nHere's the Python code:\n\n```python\nimport llm\n\n@llm.hookimpl\ndef register_template_loaders(register):\n    register(\"my-prefix\", my_template_loader)\n\ndef my_template_loader(template_path: str) -> llm.Template:\n    \"\"\"\n    Documentation for the template loader goes here. It will be displayed\n    when users run the 'llm templates loaders' command.\n    \"\"\"\n    try:\n        # Your logic to fetch the template content\n        # This is just an example:\n        prompt = \"This is a sample prompt for {}\".format(template_path)\n        system = \"You are an assistant specialized in {}\".format(template_path)\n\n        # Return a Template object with the required fields\n        return llm.Template(\n            name=template_path,\n            prompt=prompt,\n            system=system,\n        )\n    except Exception as e:\n        # Raise a ValueError with a clear message if the template cannot be found\n        raise ValueError(f\"Template '{template_path}' could not be loaded: {str(e)}\")\n```\nThe `llm.Template` class has the following constructor:\n\n```{eval-rst}\n.. autoclass:: llm.Template\n```\n\nThe loader function should raise a `ValueError` if the template cannot be found or loaded correctly, providing a clear error message.\n\nNote 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.\n\n(plugin-hooks-register-fragment-loaders)=\n## register_fragment_loaders(register)\n\nPlugins can register new fragment loaders using the `register_template_loaders` hook. These can then be used with the `llm -f prefix:argument` syntax.\n\nFragment loader plugins differ from template loader plugins in that you can stack more than one fragment loader call together in the same prompt.\n\nA 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.\n\nThe `prefix` specifies the loader. The `argument` will be passed to that registered callback..\n\nThe 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.\n\nThe `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.\n\nSee {ref}`the Python API documentation for attachments <python-api-attachments>` for details of the `llm.Attachment` class.\n\nHere is some example code:\n\n```python\nimport llm\n\n@llm.hookimpl\ndef register_fragment_loaders(register):\n    register(\"my-fragments\", my_fragment_loader)\n\n\ndef my_fragment_loader(argument: str) -> llm.Fragment:\n    \"\"\"\n    Documentation for the fragment loader goes here. It will be displayed\n    when users run the 'llm fragments loaders' command.\n    \"\"\"\n    try:\n        fragment = \"Fragment content for {}\".format(argument)\n        source = \"my-fragments:{}\".format(argument)\n        return llm.Fragment(fragment, source)\n    except Exception as ex:\n        # Raise a ValueError with a clear message if the fragment cannot be loaded\n        raise ValueError(\n            f\"Fragment 'my-fragments:{argument}' could not be loaded: {str(ex)}\"\n        )\n\n# Or for the case where you want to return multiple fragments and attachments:\ndef my_fragment_loader(argument: str) -> list[llm.Fragment]:\n    \"Docs go here.\"\n    return [\n        llm.Fragment(\"Fragment 1 content\", \"my-fragments:{argument}\"),\n        llm.Fragment(\"Fragment 2 content\", \"my-fragments:{argument}\"),\n        llm.Attachment(path=\"/path/to/image.png\"),\n    ]\n```\nA plugin like this one can be called like so:\n```bash\nllm -f my-fragments:argument\n```\nIf multiple fragments are returned they will be used as if the user passed multiple `-f X` arguments to the command.\n\nMultiple 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-duplicatino 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.\n"
  },
  {
    "path": "docs/plugins/plugin-utilities.md",
    "content": "(plugin-utilities)=\n# Utility functions for plugins\n\nLLM provides some utility functions that may be useful to plugins.\n\n(plugin-utilities-get-key)=\n## llm.get_key()\n\nThis method can be used to look up secrets that users have stored using the {ref}`llm keys set <help-keys-set>` command. If your plugin needs to access an API key or other secret this can be a convenient way to provide that.\n\nThis returns either a string containing the key or `None` if the key could not be resolved.\n\nUse the `alias=\"name\"` option to retrieve the key set with that alias:\n\n```python\ngithub_key = llm.get_key(alias=\"github\")\n```\nYou can also add `env=\"ENV_VAR\"` to fall back to looking in that environment variable if the key has not been configured:\n```python\ngithub_key = llm.get_key(alias=\"github\", env=\"GITHUB_TOKEN\")\n```\nIn 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:\n\n```python\ngithub_key = llm.get_key(input=input_from_user, alias=\"github\", env=\"GITHUB_TOKEN\")\n```\n\nAn 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.\n\n(plugin-utilities-user-dir)=\n## llm.user_dir()\n\nLLM stores various pieces of logging and configuration data in a directory on the user's machine.\n\nOn macOS this directory is `~/Library/Application Support/io.datasette.llm`, but this will differ on other operating systems.\n\nThe `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.\n\nPlugins can use this to store their own data in a subdirectory of this directory.\n\n```python\nimport llm\nuser_dir = llm.user_dir()\nplugin_dir = data_path = user_dir / \"my-plugin\"\nplugin_dir.mkdir(exist_ok=True)\ndata_path = plugin_dir / \"plugin-data.db\"\n```\n\n(plugin-utilities-modelerror)=\n## llm.ModelError\n\nIf your model encounters an error that should be reported to the user you can raise this exception. For example:\n\n```python\nimport llm\n\nraise ModelError(\"MPT model not installed - try running 'llm mpt30b download'\")\n```\nThis will be caught by the CLI layer and displayed to the user as an error message.\n\n(plugin-utilities-response-fake)=\n## Response.fake()\n\nWhen 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):\n\n```python\ndef test_build_prompt_conversation():\n    model = llm.get_model(\"mpt\")\n    conversation = model.conversation()\n    conversation.responses = [\n        llm.Response.fake(model, \"prompt 1\", \"system 1\", \"response 1\"),\n        llm.Response.fake(model, \"prompt 2\", None, \"response 2\"),\n        llm.Response.fake(model, \"prompt 3\", None, \"response 3\"),\n    ]\n    lines = model.build_prompt(llm.Prompt(\"prompt 4\", model), conversation)\n    assert lines == [\n        \"<|im_start|>system\\system 1<|im_end|>\\n\",\n        \"<|im_start|>user\\nprompt 1<|im_end|>\\n\",\n        \"<|im_start|>assistant\\nresponse 1<|im_end|>\\n\",\n        \"<|im_start|>user\\nprompt 2<|im_end|>\\n\",\n        \"<|im_start|>assistant\\nresponse 2<|im_end|>\\n\",\n        \"<|im_start|>user\\nprompt 3<|im_end|>\\n\",\n        \"<|im_start|>assistant\\nresponse 3<|im_end|>\\n\",\n        \"<|im_start|>user\\nprompt 4<|im_end|>\\n\",\n        \"<|im_start|>assistant\\n\",\n    ]\n```\nThe signature of `llm.Response.fake()` is:\n\n```python\ndef fake(cls, model: Model, prompt: str, system: str, response: str):\n```\n"
  },
  {
    "path": "docs/plugins/tutorial-model-plugin.md",
    "content": "(tutorial-model-plugin)=\n\n# Developing a model plugin\n\nThis tutorial will walk you through developing a new plugin for LLM that adds support for a new Large Language Model.\n\nWe 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.\n\n(tutorial-model-plugin-initial)=\n\n## The initial structure of the plugin\n\nFirst create a new directory with the name of your plugin - it should be called something like `llm-markov`.\n```bash\nmkdir llm-markov\ncd llm-markov\n```\nIn that directory create a file called `llm_markov.py` containing this:\n\n```python\nimport llm\n\n@llm.hookimpl\ndef register_models(register):\n    register(Markov())\n\nclass Markov(llm.Model):\n    model_id = \"markov\"\n\n    def execute(self, prompt, stream, response, conversation):\n        return [\"hello world\"]\n```\n\nThe `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.\n\nThe `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.\n\nThe logic for executing the model goes in the `execute()` method. We'll extend this to do something more useful in a later step.\n\nNext, create a `pyproject.toml` file. This is necessary to tell LLM how to load your plugin:\n\n```toml\n[project]\nname = \"llm-markov\"\nversion = \"0.1\"\n\n[project.entry-points.llm]\nmarkov = \"llm_markov\"\n```\n\nThis 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.\n\nIf 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.\n\nIf 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).\n\n(tutorial-model-plugin-installing)=\n\n## Installing your plugin to try it out\n\nHaving 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:\n\n```bash\nllm install -e .\n```\n\nThe `-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.\n\nThe `.` means the current directory. You can also install editable plugins by passing a path to their directory this:\n```bash\nllm install -e path/to/llm-markov\n```\nTo confirm that your plugin has installed correctly, run this command:\n```bash\nllm plugins\n```\nThe output should look like this:\n```json\n[\n  {\n    \"name\": \"llm-markov\",\n    \"hooks\": [\n      \"register_models\"\n    ],\n    \"version\": \"0.1\"\n  },\n  {\n    \"name\": \"llm.default_plugins.openai_models\",\n    \"hooks\": [\n      \"register_commands\",\n      \"register_models\"\n    ]\n  }\n]\n```\nThis command lists default plugins that are included with LLM as well as new plugins that have been installed.\n\nNow let's try the plugin by running a prompt through it:\n```bash\nllm -m markov \"the cat sat on the mat\"\n```\nIt outputs:\n```\nhello world\n```\nNext, we'll make it execute and return the results of a Markov chain.\n\n(tutorial-model-plugin-building)=\n\n## Building the Markov chain\n\nMarkov 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.\n\nHere's what that index looks like for the phrase \"the cat sat on the mat\"\n```json\n{\n  \"the\": [\"cat\", \"mat\"],\n  \"cat\": [\"sat\"],\n  \"sat\": [\"on\"],\n  \"on\": [\"the\"]\n}\n```\nHere's a Python function that builds that data structure from a text input:\n```python\ndef build_markov_table(text):\n    words = text.split()\n    transitions = {}\n    # Loop through all but the last word\n    for i in range(len(words) - 1):\n        word = words[i]\n        next_word = words[i + 1]\n        transitions.setdefault(word, []).append(next_word)\n    return transitions\n```\nWe can try that out by pasting it into the interactive Python interpreter and running this:\n```pycon\n>>> transitions = build_markov_table(\"the cat sat on the mat\")\n>>> transitions\n{'the': ['cat', 'mat'], 'cat': ['sat'], 'sat': ['on'], 'on': ['the']}\n```\n\n(tutorial-model-plugin-executing)=\n\n## Executing the Markov chain\n\nTo 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.\n\nSome 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.\n\nWe will implement this as a [Python generator](https://realpython.com/introduction-to-python-generators/), using the yield keyword to produce each token:\n```python\ndef generate(transitions, length, start_word=None):\n    all_words = list(transitions.keys())\n    next_word = start_word or random.choice(all_words)\n    for i in range(length):\n        yield next_word\n        options = transitions.get(next_word) or all_words\n        next_word = random.choice(options)\n```\nIf 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:\n```python\ndef generate_list(transitions, length, start_word=None):\n    all_words = list(transitions.keys())\n    next_word = start_word or random.choice(all_words)\n    output = []\n    for i in range(length):\n        output.append(next_word)\n        options = transitions.get(next_word) or all_words\n        next_word = random.choice(options)\n    return output\n```\nYou can try out the `generate()` function like this:\n```python\nlookup = build_markov_table(\"the cat sat on the mat\")\nfor word in generate(transitions, 20):\n    print(word)\n```\nOr you can generate a full string sentence with it like this:\n```python\nsentence = \" \".join(generate(transitions, 20))\n```\n\n(tutorial-model-plugin-register)=\n\n## Adding that to the plugin\n\nOur `execute()` method from earlier currently returns the list `[\"hello world\"]`.\n\nUpdate that to use our new Markov chain generator instead. Here's the full text of the new `llm_markov.py` file:\n\n```python\nimport llm\nimport random\n\n@llm.hookimpl\ndef register_models(register):\n    register(Markov())\n\ndef build_markov_table(text):\n    words = text.split()\n    transitions = {}\n    # Loop through all but the last word\n    for i in range(len(words) - 1):\n        word = words[i]\n        next_word = words[i + 1]\n        transitions.setdefault(word, []).append(next_word)\n    return transitions\n\ndef generate(transitions, length, start_word=None):\n    all_words = list(transitions.keys())\n    next_word = start_word or random.choice(all_words)\n    for i in range(length):\n        yield next_word\n        options = transitions.get(next_word) or all_words\n        next_word = random.choice(options)\n\nclass Markov(llm.Model):\n    model_id = \"markov\"\n\n    def execute(self, prompt, stream, response, conversation):\n        text = prompt.prompt\n        transitions = build_markov_table(text)\n        for word in generate(transitions, 20):\n            yield word + ' '\n```\nThe `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.\n\nNow when you run this you should see the output of the Markov chain!\n```bash\nllm -m markov \"the cat sat on the mat\"\n```\n```\nthe mat the cat sat on the cat sat on the mat cat sat on the mat cat sat on\n```\n\n (tutorial-model-plugin-execute)=\n\n## Understanding execute()\n\nThe full signature of the `execute()` method is:\n```python\ndef execute(self, prompt, stream, response, conversation):\n```\nThe `prompt` argument is a `Prompt` object that contains the text that the user provided, the system prompt and the provided options.\n\n`stream` is a boolean that says if the model is being run in streaming mode.\n\n`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.\n\n`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.\n\n(tutorial-model-plugin-logging)=\n\n## Prompts and responses are logged to the database\n\nThe 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:\n```\nllm logs -n 1\n```\nThe output should look something like this:\n```json\n[\n  {\n    \"id\": \"01h52s4yez2bd1qk2deq49wk8h\",\n    \"model\": \"markov\",\n    \"prompt\": \"the cat sat on the mat\",\n    \"system\": null,\n    \"prompt_json\": null,\n    \"options_json\": {},\n    \"response\": \"on the cat sat on the cat sat on the mat cat sat on the cat sat on the cat \",\n    \"response_json\": null,\n    \"conversation_id\": \"01h52s4yey7zc5rjmczy3ft75g\",\n    \"duration_ms\": 0,\n    \"datetime_utc\": \"2023-07-11T15:29:34.685868\",\n    \"conversation_name\": \"the cat sat on the mat\",\n    \"conversation_model\": \"markov\"\n  }\n]\n```\nPlugins can log additional information to the database by assigning a dictionary to the `response.response_json` property during the `execute()` method.\n\nHere's how to include that full `transitions` table in the `response_json` in the log:\n```python\n    def execute(self, prompt, stream, response, conversation):\n        text = self.prompt.prompt\n        transitions = build_markov_table(text)\n        for word in generate(transitions, 20):\n            yield word + ' '\n        response.response_json = {\"transitions\": transitions}\n```\n\nNow when you run the logs command you'll see that too:\n```bash\nllm logs -n 1\n```\n```json\n[\n  {\n    \"id\": 623,\n    \"model\": \"markov\",\n    \"prompt\": \"the cat sat on the mat\",\n    \"system\": null,\n    \"prompt_json\": null,\n    \"options_json\": {},\n    \"response\": \"on the mat the cat sat on the cat sat on the mat sat on the cat sat on the \",\n    \"response_json\": {\n      \"transitions\": {\n        \"the\": [\n          \"cat\",\n          \"mat\"\n        ],\n        \"cat\": [\n          \"sat\"\n        ],\n        \"sat\": [\n          \"on\"\n        ],\n        \"on\": [\n          \"the\"\n        ]\n      }\n    },\n    \"reply_to_id\": null,\n    \"chat_id\": null,\n    \"duration_ms\": 0,\n    \"datetime_utc\": \"2023-07-06T01:34:45.376637\"\n  }\n]\n```\nIn 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.\n\n(tutorial-model-plugin-options)=\n\n## Adding options\n\nLLM models can take options. For large language models these can be things like `temperature` or `top_k`.\n\nOptions are passed using the `-o/--option` command line parameters, for example:\n```bash\nllm -m gpt4 \"ten pet pelican names\" -o temperature 1.5\n```\nWe're going to add two options to our Markov chain model:\n\n- `length`: Number of words to generate\n- `delay`: a floating point number of Delay in between output token\n\nThe `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.\n\nOptions are defined using an inner class on the model, called `Options`. It should extend the `llm.Options` class.\n\nFirst, add this import to the top of your `llm_markov.py` file:\n```python\nfrom typing import Optional\n```\nThen add this `Options` class to your model:\n```python\nclass Markov(Model):\n    model_id = \"markov\"\n\n    class Options(llm.Options):\n        length: Optional[int] = None\n        delay: Optional[float] = None\n```\nLet's add extra validation rules to our options. Length must be at least 2. Duration must be between 0 and 10.\n\nThe `Options` class uses [Pydantic 2](https://pydantic.dev/), which can support all sorts of advanced validation rules.\n\nWe can also add inline documentation, which can then be displayed by the `llm models --options` command.\n\nAdd these imports to the top of `llm_markov.py`:\n```python\nfrom pydantic import field_validator, Field\n```\n\nWe can now add Pydantic field validators for our two new rules, plus inline documentation:\n\n```python\n    class Options(llm.Options):\n        length: Optional[int] = Field(\n            description=\"Number of words to generate\",\n            default=None\n        )\n        delay: Optional[float] = Field(\n            description=\"Seconds to delay between each token\",\n            default=None\n        )\n\n        @field_validator(\"length\")\n        def validate_length(cls, length):\n            if length is None:\n                return None\n            if length < 2:\n                raise ValueError(\"length must be >= 2\")\n            return length\n\n        @field_validator(\"delay\")\n        def validate_delay(cls, delay):\n            if delay is None:\n                return None\n            if not 0 <= delay <= 10:\n                raise ValueError(\"delay must be between 0 and 10\")\n            return delay\n```\nLets test our options validation:\n```bash\nllm -m markov \"the cat sat on the mat\" -o length -1\n```\n```\nError: length\n  Value error, length must be >= 2\n```\n\nNext, we will modify our `execute()` method to handle those options. Add this to the beginning of `llm_markov.py`:\n```python\nimport time\n```\nThen replace the `execute()` method with this one:\n```python\n    def execute(self, prompt, stream, response, conversation):\n        text = prompt.prompt\n        transitions = build_markov_table(text)\n        length = prompt.options.length or 20\n        for word in generate(transitions, length):\n            yield word + ' '\n            if prompt.options.delay:\n                time.sleep(prompt.options.delay)\n```\nAdd `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.\n\nThe full `llm_markov.py` file should now look like this:\n\n```{literalinclude} llm-markov/llm_markov.py\n:language: python\n```\n\nNow we can request a 20 word completion with a 0.1s delay between tokens like this:\n```bash\nllm -m markov \"the cat sat on the mat\" \\\n  -o length 20 -o delay 0.1\n```\nLLM 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:\n```bash\nllm -m markov \"the cat sat on the mat\" \\\n  -o length 20 -o delay 0.1 --no-stream\n```\nIn this case it will still delay for 2s total while it gathers the tokens, then output them all at once.\n\nThat `--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.\n\nOptions are also logged to the database. You can see those here:\n```bash\nllm logs -n 1\n```\n```json\n[\n  {\n    \"id\": 636,\n    \"model\": \"markov\",\n    \"prompt\": \"the cat sat on the mat\",\n    \"system\": null,\n    \"prompt_json\": null,\n    \"options_json\": {\n      \"length\": 20,\n      \"delay\": 0.1\n    },\n    \"response\": \"the mat on the mat on the cat sat on the mat sat on the mat cat sat on the \",\n    \"response_json\": null,\n    \"reply_to_id\": null,\n    \"chat_id\": null,\n    \"duration_ms\": 2063,\n    \"datetime_utc\": \"2023-07-07T03:02:28.232970\"\n  }\n]\n```\n\n(tutorial-model-plugin-distributing)=\n\n## Distributing your plugin\n\nThere are many different options for distributing your new plugin so other people can try it out.\n\nYou can create a downloadable wheel or `.zip` or `.tar.gz` files, or share the plugin through GitHub Gists or repositories.\n\nYou can also publish your plugin to PyPI, the Python Package Index.\n\n(tutorial-model-plugin-wheels)=\n\n### Wheels and sdist packages\n\nThe easiest option is to produce a distributable package is to use the `build` command. First, install the `build` package by running this:\n```bash\npython -m pip install build\n```\nThen run `build` in your plugin directory to create the packages:\n```bash\npython -m build\n```\nThis will create two files: `dist/llm-markov-0.1.tar.gz` and `dist/llm-markov-0.1-py3-none-any.whl`.\n\nEither of these files can be used to install the plugin:\n\n```bash\nllm install dist/llm_markov-0.1-py3-none-any.whl\n```\nIf you host this file somewhere online other people will be able to install it using `pip install` against the URL to your package:\n```bash\nllm install 'https://.../llm_markov-0.1-py3-none-any.whl'\n```\nYou can run the following command at any time to uninstall your plugin, which is useful for testing out different installation methods:\n```bash\nllm uninstall llm-markov -y\n```\n\n(tutorial-model-plugin-gists)=\n\n### GitHub Gists\n\nA 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`.\n\nHere's an example Gist I created for this tutorial:\n\n[https://gist.github.com/simonw/6e56d48dc2599bffba963cef0db27b6d](https://gist.github.com/simonw/6e56d48dc2599bffba963cef0db27b6d)\n\nYou 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:\n\n`https://gist.github.com/simonw/6e56d48dc2599bffba963cef0db27b6d/archive/cc50c854414cb4deab3e3ab17e7e1e07d45cba0c.zip`\n\nThe plugin can be installed using the `llm install` command like this:\n```bash\nllm install 'https://gist.github.com/simonw/6e56d48dc2599bffba963cef0db27b6d/archive/cc50c854414cb4deab3e3ab17e7e1e07d45cba0c.zip'\n```\n\n(tutorial-model-plugin-github)=\n\n## GitHub repositories\n\nThe 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.\n\n(tutorial-model-plugin-pypi)=\n\n## Publishing plugins to PyPI\n\nThe [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 <name>`.\n\nFollow [these instructions](https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives) to publish a package to PyPI. The short version:\n```bash\npython -m pip install twine\npython -m twine upload dist/*\n```\nYou 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.\n\n(tutorial-model-plugin-metadata)=\n\n## Adding metadata\n\nBefore uploading a package to PyPI it's a good idea to add documentation and expand `pyproject.toml` with additional metadata.\n\nCreate a `README.md` file in the root of your plugin directory with instructions about how to install, configure and use your plugin.\n\nYou can then replace `pyproject.toml` with something like this:\n\n```toml\n[project]\nname = \"llm-markov\"\nversion = \"0.1\"\ndescription = \"Plugin for LLM adding a Markov chain generating model\"\nreadme = \"README.md\"\nauthors = [{name = \"Simon Willison\"}]\nlicense = {text = \"Apache-2.0\"}\nclassifiers = [\n    \"License :: OSI Approved :: Apache Software License\"\n]\ndependencies = [\n    \"llm\"\n]\nrequires-python = \">3.7\"\n\n[project.urls]\nHomepage = \"https://github.com/simonw/llm-markov\"\nChangelog = \"https://github.com/simonw/llm-markov/releases\"\nIssues = \"https://github.com/simonw/llm-markov/issues\"\n\n[project.entry-points.llm]\nmarkov = \"llm_markov\"\n```\nThis will pull in your README to be displayed as part of your project's listing page on PyPI.\n\nIt adds `llm` as a dependency, ensuring it will be installed if someone tries to install your plugin package without it.\n\nIt adds some links to useful pages (you can drop the `project.urls` section if those links are not useful for your project).\n\nYou 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).\n\n(tutorial-model-plugin-breaks)=\n\n## What to do if it breaks\n\nSometimes 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:\n\n```\n$ llm 'hi'\nTraceback (most recent call last):\n  ...\n  File llm-markov/llm_markov.py\", line 10\n    register(Markov()):\n                      ^\nSyntaxError: invalid syntax\n```\nYou may find that you are unable to uninstall the plugin using `llm uninstall llm-markov` because the command itself fails with the same error.\n\nShould this happen, you can uninstall the plugin after first disabling it using the {ref}`LLM_LOAD_PLUGINS <llm-load-plugins>` environment variable like this:\n```bash\nLLM_LOAD_PLUGINS='' llm uninstall llm-markov\n```\n"
  },
  {
    "path": "docs/python-api.md",
    "content": "(python-api)=\n# Python API\n\nLLM provides a Python API for executing prompts, in addition to the command-line interface.\n\nUnderstanding this API is also important for writing {ref}`plugins`.\n\n## Basic prompt execution\n\nTo run a prompt against the `gpt-4o-mini` model, run this:\n\n```python\nimport llm\n\nmodel = llm.get_model(\"gpt-4o-mini\")\n# key= is optional, you can configure the key in other ways\nresponse = model.prompt(\n    \"Five surprising names for a pet pelican\",\n    key=\"sk-...\"\n)\nprint(response.text())\n```\nNote that the prompt will not be evaluated until you call that `response.text()` method - a form of lazy loading.\n\nIf you inspect the response before it has been evaluated it will look like this:\n\n    <Response prompt='Your prompt' text='... not yet done ...'>\n\nThe `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.\n\nIn 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`.\n\nThe `__str__()` method of `response` also returns the text of the response, so you can do this instead:\n\n```python\nprint(llm.get_model().prompt(\"Five surprising names for a pet pelican\"))\n```\n\nYou can run this command to see a list of available models and their aliases:\n\n```bash\nllm models\n```\nIf you have set a `OPENAI_API_KEY` environment variable you can omit the `model.key = ` line.\n\nCalling `llm.get_model()` with an invalid model ID will raise a `llm.UnknownModelError` exception.\n\n(python-api-system-prompts)=\n\n### System prompts\n\nFor models that accept a system prompt, pass it as `system=\"...\"`:\n\n```python\nresponse = model.prompt(\n    \"Five surprising names for a pet pelican\",\n    system=\"Answer like GlaDOS\"\n)\n```\n\n(python-api-attachments)=\n\n### Attachments\n\nModels 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.\n\nThis example shows two attachments - one from a file path and one from a URL:\n```python\nimport llm\n\nmodel = llm.get_model(\"gpt-4o-mini\")\nresponse = model.prompt(\n    \"Describe these images\",\n    attachments=[\n        llm.Attachment(path=\"pelican.jpg\"),\n        llm.Attachment(url=\"https://static.simonwillison.net/static/2024/pelicans.jpg\"),\n    ]\n)\n```\nUse `llm.Attachment(content=b\"binary image content here\")` to pass binary content directly.\n\nYou can check which attachment types (if any) a model supports using the `model.attachment_types` set:\n\n```python\nmodel = llm.get_model(\"gpt-4o-mini\")\nprint(model.attachment_types)\n# {'image/gif', 'image/png', 'image/jpeg', 'image/webp'}\n\nif \"image/jpeg\" in model.attachment_types:\n    # Use a JPEG attachment here\n    ...\n```\n\n(python-api-tools)=\n\n### Tools\n\n{ref}`Tools <tools>` are functions that can be executed by the model as part of a chain of responses.\n\nYou 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:\n\n```python\nimport llm\n\ndef upper(text: str) -> str:\n    \"\"\"Convert text to uppercase.\"\"\"\n    return text.upper()\n\nmodel = llm.get_model(\"gpt-4.1-mini\")\nresponse = model.prompt(\"Convert panda to upper\", tools=[upper])\ntool_calls = response.tool_calls()\n# [ToolCall(name='upper', arguments={'text': 'panda'}, tool_call_id='...')]\n```\nYou can call `response.execute_tool_calls()` to execute those calls and get back the results:\n```python\ntool_results = response.execute_tool_calls()\n# [ToolResult(name='upper', output='PANDA', tool_call_id='...')]\n```\nYou can use the `model.chain()` to pass the results of tool calls back to the model automatically as subsequent prompts:\n```python\nchain_response = model.chain(\n    \"Convert panda to upper\",\n    tools=[upper],\n)\nprint(chain_response.text())\n# The word \"panda\" converted to uppercase is \"PANDA\".\n```\nYou can also loop through the `model.chain()` response to get a stream of tokens, like this:\n```python\nfor chunk in model.chain(\n    \"Convert panda to upper\",\n    tools=[upper],\n):\n    print(chunk, end=\"\", flush=True)\n```\nThis will stream each of the chain of responses in turn as they are generated.\n\nYou can access the individual responses that make up the chain using `chain.responses()`. This can be iterated over as the chain executes like this:\n\n```python\nchain = model.chain(\n    \"Convert panda to upper\",\n    tools=[upper],\n)\nfor response in chain.responses():\n    print(response.prompt)\n    for chunk in response:\n        print(chunk, end=\"\", flush=True)\n```\n\n(python-api-tools-debug-hooks)=\n\n#### Tool debugging hooks\n\nPass 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.\n\nThe 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.\n\nHere's an example:\n```python\nimport llm\nfrom typing import Optional\n\ndef upper(text: str) -> str:\n    \"Convert text to uppercase.\"\n    return text.upper()\n\ndef before_call(tool: Optional[llm.Tool], tool_call: llm.ToolCall):\n    print(f\"About to call tool {tool.name} with arguments {tool_call.arguments}\")\n    if tool.name == \"upper\" and \"bad\" in repr(tool_call.arguments):\n        raise llm.CancelToolCall(\"Not allowed to call upper on text containing 'bad'\")\n\nmodel = llm.get_model(\"gpt-4.1-mini\")\nresponse = model.chain(\n    \"Convert panda to upper and badger to upper\",\n    tools=[upper],\n    before_call=before_call,\n)\nprint(response.text())\n```\nIf you raise `llm.CancelToolCall` in the `before_call` function the model will be informed that the tool call was cancelled.\n\nThe `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:\n```python\ndef after_call(tool: llm.Tool, tool_call: llm.ToolCall, tool_result: llm.ToolResult):\n    print(f\"Tool {tool.name} called with arguments {tool_call.arguments} returned {tool_result.output}\")\n\nresponse = model.chain(\n    \"Convert panda to upper and badger to upper\",\n    tools=[upper],\n    after_call=after_call,\n)\nprint(response.text())\n```\n\n(python-api-tools-attachments)=\n\n#### Tools can return attachments\n\nTools can return {ref}`attachments <python-api-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.\n\nTo 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.\n\nHere's an example:\n```python\nimport llm\n\ndef generate_image(prompt: str) -> llm.ToolOutput:\n    \"\"\"Generate an image based on the prompt.\"\"\"\n    image_content = generate_image_from_prompt(prompt)\n    return llm.ToolOutput(\n        output=\"Image generated successfully\",\n        attachments=[llm.Attachment(\n            content=image_content,\n            mimetype=\"image/png\"\n        )],\n    )\n```\n\n(python-api-toolbox)=\n\n#### Toolbox classes\n\nFunctions 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:\n\n- Toolbox tools can bundle multiple tools together\n- Toolbox tools can be configured, e.g. to give filesystem tools access to a specific directory\n- Toolbox instances can persist shared state in between tool invocations\n\nToolboxes are classes that extend `llm.Toolbox`. Any methods that do not begin with an underscore will be exposed as tool functions.\n\nThis example sets up key/value memory storage that can be used by the model:\n```python\nimport llm\n\nclass Memory(llm.Toolbox):\n    _memory = None\n\n    def _get_memory(self):\n        if self._memory is None:\n            self._memory = {}\n        return self._memory\n\n    def set(self, key: str, value: str):\n        \"Set something as a key\"\n        self._get_memory()[key] = value\n\n    def get(self, key: str):\n        \"Get something from a key\"\n        return self._get_memory().get(key) or \"\"\n\n    def append(self, key: str, value: str):\n        \"Append something as a key\"\n        memory = self._get_memory()\n        memory[key] = (memory.get(key) or \"\") + \"\\n\" + value\n\n    def keys(self):\n        \"Return a list of keys\"\n        return list(self._get_memory().keys())\n```\nYou can then use that from Python like this:\n```python\nmodel = llm.get_model(\"gpt-4.1-mini\")\nmemory = Memory()\n\nconversation = model.conversation(tools=[memory])\nprint(conversation.chain(\"Set name to Simon\", after_call=print).text())\n\nprint(memory._memory)\n# Should show {'name': 'Simon'}\n\nprint(conversation.chain(\"Set name to Penguin\", after_call=print).text())\n# Now it should be {'name': 'Penguin'}\n\nprint(conversation.chain(\"Print current name\", after_call=print).text())\n```\n\nSee the {ref}`register_tools() plugin hook documentation <plugin-hooks-register-tools>` for an example of this tool in action as a CLI plugin.\n\n(python-api-tools-dynamic)=\n#### Dynamic toolboxes\n\nSometimes 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.\n\nYou can use the `toolbox.add_tool(function_or_tool)` method to add a new tool to an existing toolbox.\n\nThis can be passed a `llm.Tool` instance or a function that will be converted into a tool automatically.\n\nIf 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:\n\n```python\ndef my_function(self, arg1: str, arg2: int) -> str:\n    return f\"Received {arg1} and {arg2} in {self}\"\n\ntoolbox.add_tool(my_function, pass_self=True)\n```\nWithout `pass_self=True` the function will be called with only its declared arguments, with no `self` parameter.\n\nIf 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.\n\nIn 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()`.\n\nIf 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.\n\n(python-api-schemas)=\n\n### Schemas\n\nAs with {ref}`the CLI tool <usage-schemas>` some models support passing a JSON schema should be used for the resulting response.\n\nYou can pass this to the `prompt(schema=)` parameter as either a Python dictionary or a [Pydantic](https://docs.pydantic.dev/) `BaseModel` subclass:\n\n```python\nimport llm, json\nfrom pydantic import BaseModel\n\nclass Dog(BaseModel):\n    name: str\n    age: int\n\nmodel = llm.get_model(\"gpt-4o-mini\")\nresponse = model.prompt(\"Describe a nice dog\", schema=Dog)\ndog = json.loads(response.text())\nprint(dog)\n# {\"name\":\"Buddy\",\"age\":3}\n```\nYou can also pass a schema directly, like this:\n```python\nresponse = model.prompt(\"Describe a nice dog\", schema={\n    \"properties\": {\n        \"name\": {\"title\": \"Name\", \"type\": \"string\"},\n        \"age\": {\"title\": \"Age\", \"type\": \"integer\"},\n    },\n    \"required\": [\"name\", \"age\"],\n    \"title\": \"Dog\",\n    \"type\": \"object\",\n})\n```\n\nYou can also use LLM's {ref}`alternative schema syntax <schemas-dsl>` via the `llm.schema_dsl(schema_dsl)` function. This provides a quick way to construct a JSON schema for simple cases:\n\n```python\nprint(model.prompt(\n    \"Describe a nice dog with a surprising name\",\n    schema=llm.schema_dsl(\"name, age int, bio\")\n))\n```\nPass `multi=True` to generate a schema that returns multiple items matching that specification:\n\n```python\nprint(model.prompt(\n    \"Describe 3 nice dogs with surprising names\",\n    schema=llm.schema_dsl(\"name, age int, bio\", multi=True)\n))\n```\n\n(python-api-fragments)=\n\n### Fragments\n\nThe {ref}`fragment system <usage-fragments>` 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:\n\n```python\nresponse = model.prompt(\n    \"What do these documents say about dogs?\",\n    fragments=[\n        open(\"dogs1.txt\").read(),\n        open(\"dogs2.txt\").read(),\n    ],\n    system_fragments=[\n        \"You answer questions like Snoopy\",\n    ]\n)\n```\nThis mechanism has limited utility in Python, as you can also assemble the contents of these strings together into the `prompt=` and `system=` strings directly.\n\nFragments 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.\n\nSome 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.\n\n\n(python-api-model-options)=\n\n### Model options\n\nFor models that support options (view those with `llm models --options`) you can pass options as keyword arguments to the `.prompt()` method:\n\n```python\nmodel = llm.get_model()\nprint(model.prompt(\"Names for otters\", temperature=0.2))\n```\n\n(python-api-models-api-keys)=\n\n### Passing an API key\n\nModels that accept API keys should take an additional `key=` parameter to their `model.prompt()` method:\n\n```python\nmodel = llm.get_model(\"gpt-4o-mini\")\nprint(model.prompt(\"Names for beavers\", key=\"sk-...\"))\n```\n\nIf 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 <api-keys>` command.\n\nSome 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.\n\n(python-api-models-from-plugins)=\n\n### Models from plugins\n\nAny 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):\n\n```bash\npip install llm-anthropic\n```\nThen in your Python code:\n```python\nimport llm\n\nmodel = llm.get_model(\"claude-3.5-sonnet\")\n# Use this if you have not set the key using 'llm keys set claude':\nmodel.key = 'YOUR_API_KEY_HERE'\nresponse = model.prompt(\"Five surprising names for a pet pelican\")\nprint(response.text())\n```\nSome models do not use API keys at all.\n\n(python-api-underlying-json)=\n\n### Accessing the underlying JSON\n\nMost 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.\n\nYou can access this JSON data as a Python dictionary using the `response.json()` method:\n\n```python\nimport llm\nfrom pprint import pprint\n\nmodel = llm.get_model(\"gpt-4o-mini\")\nresponse = model.prompt(\"3 names for an otter\")\njson_data = response.json()\npprint(json_data)\n```\nHere's that example output from GPT-4o mini:\n```python\n{'content': 'Sure! Here are three fun names for an otter:\\n'\n            '\\n'\n            '1. **Splash**\\n'\n            '2. **Bubbles**\\n'\n            '3. **Otto** \\n'\n            '\\n'\n            'Feel free to mix and match or use these as inspiration!',\n 'created': 1739291215,\n 'finish_reason': 'stop',\n 'id': 'chatcmpl-AznO31yxgBjZ4zrzBOwJvHEWgdTaf',\n 'model': 'gpt-4o-mini-2024-07-18',\n 'object': 'chat.completion.chunk',\n 'usage': {'completion_tokens': 43,\n           'completion_tokens_details': {'accepted_prediction_tokens': 0,\n                                         'audio_tokens': 0,\n                                         'reasoning_tokens': 0,\n                                         'rejected_prediction_tokens': 0},\n           'prompt_tokens': 13,\n           'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0},\n           'total_tokens': 56}}\n```\n\n(python-api-token-usage)=\n\n### Token usage\n\nMany models can return a count of the number of tokens used while executing the prompt.\n\nThe `response.usage()` method provides an abstraction over this:\n\n```python\npprint(response.usage())\n```\nExample output:\n```python\nUsage(input=5,\n      output=2,\n      details={'candidatesTokensDetails': [{'modality': 'TEXT',\n                                            'tokenCount': 2}],\n               'promptTokensDetails': [{'modality': 'TEXT', 'tokenCount': 5}]})\n```\nThe `.input` and `.output` properties are integers representing the number of input and output tokens. The `.details` property may be a dictionary with additional custom values that vary by model.\n\n(python-api-streaming-responses)=\n\n### Streaming responses\n\nFor models that support it you can stream responses as they are generated, like this:\n\n```python\nresponse = model.prompt(\"Five diabolical names for a pet goat\")\nfor chunk in response:\n    print(chunk, end=\"\")\n```\nThe `response.text()` method described earlier does this for you - it runs through the iterator and gathers the results into a string.\n\nIf a response has been evaluated, `response.text()` will continue to return the same string.\n\n(python-api-async)=\n\n## Async models\n\nSome plugins provide async versions of their supported models, suitable for use with Python [asyncio](https://docs.python.org/3/library/asyncio.html).\n\nTo use an async model, use the `llm.get_async_model()` function instead of `llm.get_model()`:\n\n```python\nimport llm\nmodel = llm.get_async_model(\"gpt-4o\")\n```\nYou can then run a prompt using `await model.prompt(...)`:\n\n```python\nprint(await model.prompt(\n    \"Five surprising names for a pet pelican\"\n).text())\n```\nOr use `async for chunk in ...` to stream the response as it is generated:\n```python\nasync for chunk in model.prompt(\n    \"Five surprising names for a pet pelican\"\n):\n    print(chunk, end=\"\", flush=True)\n```\nThis `await model.prompt()` method takes the same arguments as the synchronous `model.prompt()` method, for options and attachments and `key=` and suchlike.\n\n(python-api-async-tools)=\n\n### Tool functions can be sync or async\n\n{ref}`Tool functions <python-api-tools>` 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.\n\nIf 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:\n\n```python\nasync def hello(name: str) -> str:\n    \"Say hello to name\"\n    return \"Hello there \" + name\n\nmodel = llm.get_model(\"gpt-4.1-mini\")\nchain_response = model.chain(\n    \"Say hello to Percival\", tools=[hello]\n)\nprint(chain_response.text())\n```\nThis also works for `async def` methods of `llm.Toolbox` subclasses.\n\n### Tool use for async models\n\nTool 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.\n\nThe `response.execute_tool_calls()` and `chain_response.text()` and `chain_response.responses()` methods must all be awaited when run against asynchronous models:\n\n```python\nimport llm\nmodel = llm.get_async_model(\"gpt-4.1\")\n\ndef upper(string):\n    \"Converts string to uppercase\"\n    return string.upper()\n\nchain = model.chain(\n    \"Convert panda to uppercase then pelican to uppercase\",\n    tools=[upper],\n    after_call=print\n)\nprint(await chain.text())\n```\n\nTo iterate over the chained response output as it arrives use `async for`:\n```python\nasync for chunk in model.chain(\n    \"Convert panda to uppercase then pelican to uppercase\",\n    tools=[upper]\n):\n    print(chunk, end=\"\", flush=True)\n```\nThe `before_call` and `after_call` hooks can be async functions when used with async models.\n\n(python-api-conversations)=\n\n## Conversations\n\nLLM supports *conversations*, where you ask follow-up questions of a model as part of an ongoing conversation.\n\nTo start a new conversation, use the `model.conversation()` method:\n\n```python\nmodel = llm.get_model()\nconversation = model.conversation()\n```\nYou can then use the `conversation.prompt()` method to execute prompts against this conversation:\n\n```python\nresponse = conversation.prompt(\"Five fun facts about pelicans\")\nprint(response.text())\n```\nThis 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:\n```python\nresponse2 = conversation.prompt(\"Now do skunks\")\nprint(response2.text())\n```\nYou will get back five fun facts about skunks.\n\nThe `conversation.prompt()` method supports attachments as well:\n```python\nresponse = conversation.prompt(\n    \"Describe these birds\",\n    attachments=[\n        llm.Attachment(url=\"https://static.simonwillison.net/static/2024/pelicans.jpg\")\n    ]\n)\n```\n\nAccess `conversation.responses` for a list of all of the responses that have so far been returned during the conversation.\n\n### Conversations using tools\n\nYou can pass a list of tool functions to the `tools=[]` argument when you start a new conversation:\n```python\nimport llm\n\ndef upper(text: str) -> str:\n    \"convert text to upper case\"\n    return text.upper()\n\ndef reverse(text: str) -> str:\n    \"reverse text\"\n    return text[::-1]\n\nmodel = llm.get_model(\"gpt-4.1-mini\")\nconversation = model.conversation(tools=[upper, reverse])\n```\nYou can then call the `conversation.chain()` method multiple times to have a conversation that uses those tools:\n```python\nprint(conversation.chain(\n    \"Convert panda to uppercase and reverse it\"\n).text())\nprint(conversation.chain(\n    \"Same with pangolin\"\n).text())\n```\nThe `before_call=` and `after_call=` parameters {ref}`described above <python-api-tools-debug-hooks>` can be passed directly to the `model.conversation()` method to set those options for all chained prompts in that conversation.\n\n\n(python-api-listing-models)=\n\n## Listing models\n\nThe `llm.get_models()` list returns a list of all available models, including those from plugins.\n\n```python\nimport llm\n\nfor model in llm.get_models():\n    print(model.model_id)\n```\n\nUse `llm.get_async_models()` to list async models:\n\n```python\nfor model in llm.get_async_models():\n    print(model.model_id)\n```\n\n(python-api-response-on-done)=\n\n## Running code when a response has completed\n\nFor 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\n\nYou 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).\n\nThe signature of the method you provide is `def callback(response)` - it can be optionally an `async def` method when working with asynchronous models.\n\nExample usage:\n\n```python\nimport llm\n\nmodel = llm.get_model(\"gpt-4o-mini\")\nresponse = model.prompt(\"a poem about a hippo\")\nresponse.on_done(lambda response: print(response.usage()))\nprint(response.text())\n```\nWhich outputs:\n```\nUsage(input=20, output=494, details={})\nIn a sunlit glade by a bubbling brook,\nLived a hefty hippo, with a curious look.\n...\n```\nOr using an `asyncio` model, where you need to `await response.on_done(done)` to queue up the callback:\n```python\nimport asyncio, llm\n\nasync def run():\n    model = llm.get_async_model(\"gpt-4o-mini\")\n    response = model.prompt(\"a short poem about a brick\")\n    async def done(response):\n        print(await response.usage())\n        print(await response.text())\n    await response.on_done(done)\n    print(await response.text())\n\nasyncio.run(run())\n```\n\n## Other functions\n\nThe `llm` top level package includes some useful utility functions.\n\n### set_alias(alias, model_id)\n\nThe `llm.set_alias()` function can be used to define a new alias:\n\n```python\nimport llm\n\nllm.set_alias(\"mini\", \"gpt-4o-mini\")\n```\nThe second argument can be a model identifier or another alias, in which case that alias will be resolved.\n\nIf the `aliases.json` file does not exist or contains invalid JSON it will be created or overwritten.\n\n### remove_alias(alias)\n\nRemoves the alias with the given name from the `aliases.json` file.\n\nRaises `KeyError` if the alias does not exist.\n\n```python\nimport llm\n\nllm.remove_alias(\"turbo\")\n```\n\n### set_default_model(alias)\n\nThis 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.\n\n```python\nimport llm\n\nllm.set_default_model(\"claude-3.5-sonnet\")\n```\n\n### get_default_model()\n\nThis returns the currently configured default model, or `gpt-4o-mini` if no default has been set.\n\n```python\nimport llm\n\nmodel_id = llm.get_default_model()\n```\n\nTo detect if no default has been set you can use this pattern:\n\n```python\nif llm.get_default_model(default=None) is None:\n    print(\"No default has been set\")\n```\nHere the `default=` parameter specifies the value that should be returned if there is no configured default.\n\n### set_default_embedding_model(alias) and get_default_embedding_model()\n\nThese two methods work the same as `set_default_model()` and `get_default_model()` but for the default {ref}`embedding model <embeddings>` instead.\n"
  },
  {
    "path": "docs/related-tools.md",
    "content": "(related-tools)=\n# Related tools\n\nThe following tools are designed to be used with LLM:\n\n(related-tools-strip-tags)=\n## strip-tags\n\n[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.\n\nHere'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\"`:\n\n```bash\ncurl -s https://www.nytimes.com/ \\\n  | strip-tags .story-wrapper \\\n  | llm -s 'summarize the news'\n```\n\n[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.\n\n(related-tools-ttok)=\n## ttok\n\n[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:\n\n```bash\ncat my-file.txt | ttok\n```\n```\n125\n```\nIt can also truncate input down to a desired number of tokens:\n```bash\nttok This is too many tokens -t 3\n```\n```\nThis is too\n```\nThis is useful for truncating a large document down to a size where it can be processed by an LLM.\n\n(related-tools-symbex)=\n## Symbex\n\n[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.\n\nHere's how to use it to find all functions that match `test*csv*` and use those to guess what the software under test does:\n\n```bash\nsymbex 'test*csv*' | \\\n  llm --system 'based on these tests guess what this tool does'\n```\nIt can also be used to export symbols in a format that can be piped to {ref}`llm embed-multi <embeddings-cli-embed-multi>` in order to create embeddings:\n```bash\nsymbex '*' '*:*' --nl | \\\n  llm embed-multi symbols - \\\n  --format nl --database embeddings.db --store\n```\nFor more examples see [Symbex: search Python code for functions and classes, then pipe them into a LLM](https://simonwillison.net/2023/Jun/18/symbex/).\n"
  },
  {
    "path": "docs/requirements.txt",
    "content": "sphinx==7.2.6\nfuro==2023.9.10\nsphinx-autobuild\nsphinx-copybutton\nsphinx-markdown-builder==0.6.8\nmyst-parser\ncogapp\n"
  },
  {
    "path": "docs/schemas.md",
    "content": "(schemas)=\n\n# Schemas\n\nLarge 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.\n\nThis feature is supported by models from OpenAI, Anthropic, Google Gemini and can be implemented for others {ref}`via plugins <advanced-model-plugins-schemas>`.\n\nThis page describes schemas used via the `llm` command-line tool. Schemas can also be used from the {ref}`Python API <python-api-schemas>`.\n\n(schemas-tutorial)=\n\n## Schemas tutorial\n\nIn this tutorial we're going to use schemas to analyze some news stories.\n\nBut first, let's invent some dogs!\n\n### Getting started with dogs\n\nLLMs are great at creating test data. Let's define a simple schema for a dog, using LLM's {ref}`concise schema syntax <schemas-dsl>`. We'll pass that to LLm with `llm --schema` and prompt it to \"invent a cool dog\":\n```bash\nllm --schema 'name, age int, one_sentence_bio' 'invent a cool dog'\n```\nI got back Ziggy:\n```json\n{\n  \"name\": \"Ziggy\",\n  \"age\": 4,\n  \"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.\"\n}\n```\nThe response matched my schema, with `name` and `one_sentence_bio` string columns and an integer for `age`.\n\nWe'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.\n\nFor a list of available models that support schemas, run this command:\n```bash\nllm models --schemas\n```\n\nWant several more dogs? You can pass in that same schema using `--schema-multi` and ask for several at once:\n```bash\nllm --schema-multi 'name, age int, one_sentence_bio' 'invent 3 really cool dogs'\n```\nHere's what I got:\n```json\n{\n  \"items\": [\n    {\n      \"name\": \"Echo\",\n      \"age\": 3,\n      \"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.\"\n    },\n    {\n      \"name\": \"Nova\",\n      \"age\": 2,\n      \"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.\"\n    },\n    {\n      \"name\": \"Pixel\",\n      \"age\": 4,\n      \"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.\"\n    }\n  ]\n}\n```\nSo 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.\n\nThis stuff gets a _lot_ more useful when you start applying it to larger amounts of text, extracting structured details from unstructured content.\n\n### Extracting people from a news articles\n\nWe are going to extract details of the people who are mentioned in different news stories, and then use those to compile a database.\n\nLet's start by compiling a schema. For each person mentioned we want to extract the following details:\n\n- Their name\n- The organization they work for\n- Their role\n- What we learned about them from the story\n\nWe will also record the article headline and the publication date, to make things easier for us later on.\n\nUsing LLM's custom, concise schema language, this time with newlines separating the individual fields (for the dogs example we used commas):\n```\nname: the person's name\norganization: who they represent\nrole: their job title or role\nlearned: what we learned about them from this story\narticle_headline: the headline of the story\narticle_date: the publication date in YYYY-MM-DD\n```\nAs 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.\n\nThe full syntax is {ref}`described below <schemas-dsl>` - you can also include type information for things like numbers.\n\nLet's run this against a news article.\n\nVisit [AP News](https://apnews.com/) and grab the URL to an article. I'm using this one:\n\n    https://apnews.com/article/trump-federal-employees-firings-a85d1aaf1088e050d39dcf7e3664bb9f\n\nThere'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:\n\n```\nuv tool install strip-tags\n# Or \"pip install\" or \"pipx install\"\n```\nNow we can run this command to extract the people from that article:\n\n```bash\ncurl 'https://apnews.com/article/trump-federal-employees-firings-a85d1aaf1088e050d39dcf7e3664bb9f' | \\\n  uvx strip-tags | \\\n  llm --schema-multi \"\nname: the person's name\norganization: who they represent\nrole: their job title or role\nlearned: what we learned about them from this story\narticle_headline: the headline of the story\narticle_date: the publication date in YYYY-MM-DD\n\" --system 'extract people mentioned in this article'\n```\nThe output I got started like this:\n```json\n{\n  \"items\": [\n    {\n      \"name\": \"William Alsup\",\n      \"organization\": \"U.S. District Court\",\n      \"role\": \"Judge\",\n      \"learned\": \"He ruled that the mass firings of probationary employees were likely unlawful and criticized the authority exercised by the Office of Personnel Management.\",\n      \"article_headline\": \"Judge finds mass firings of federal probationary workers were likely unlawful\",\n      \"article_date\": \"2025-02-26\"\n    },\n    {\n      \"name\": \"Everett Kelley\",\n      \"organization\": \"American Federation of Government Employees\",\n      \"role\": \"National President\",\n      \"learned\": \"He hailed the court's decision as a victory for employees who were illegally fired.\",\n      \"article_headline\": \"Judge finds mass firings of federal probationary workers were likely unlawful\",\n      \"article_date\": \"2025-02-26\"\n    }\n```\nThis data has been logged to LLM's {ref}`SQLite database <logging>`. We can retrieve the data back out again using the {ref}`llm logs <logging-view>` command like this:\n```bash\nllm logs -c --data\n```\nThe `-c` flag means \"use most recent conversation\", and the `--data` flag outputs just the JSON data that was captured in the response.\n\nWe'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`:\n\n```bash\nllm schemas\n```\nHere's the output:\n```\n- id: 3b7702e71da3dd791d9e17b76c88730e\n  summary: |\n    {items: [{name, organization, role, learned, article_headline, article_date}]}\n  usage: |\n    1 time, most recently 2025-02-28T04:50:02.032081+00:00\n```\nTo view the full schema, run that command with `--full`:\n\n```bash\nllm schemas --full\n```\nWhich outputs:\n```\n- id: 3b7702e71da3dd791d9e17b76c88730e\n  schema: |\n    {\n      \"type\": \"object\",\n      \"properties\": {\n        \"items\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"name\": {\n                \"type\": \"string\",\n                \"description\": \"the person's name\"\n              },\n    ...\n```\nThat `3b7702e71da3dd791d9e17b76c88730e` ID can be used to run the same schema again. Let's try that now on a different URL:\n\n```bash\ncurl 'https://apnews.com/article/bezos-katy-perry-blue-origin-launch-4a074e534baa664abfa6538159c12987' | \\\n  uvx strip-tags | \\\n  llm --schema 3b7702e71da3dd791d9e17b76c88730e \\\n    --system 'extract people mentioned in this article'\n```\nHere we are using `--schema` because our schema ID already corresponds to an array of items.\n\nThe result starts like this:\n```json\n{\n  \"items\": [\n    {\n      \"name\": \"Katy Perry\",\n      \"organization\": \"Blue Origin\",\n      \"role\": \"Singer\",\n      \"learned\": \"Katy Perry will join the all-female celebrity crew for a spaceflight organized by Blue Origin.\",\n      \"article_headline\": \"Katy Perry and Gayle King will join Jeff Bezos’ fiancee Lauren Sanchez on Blue Origin spaceflight\",\n      \"article_date\": \"2023-10-15\"\n    },\n```\nOne more trick: let's turn our schema and system prompt combination into a {ref}`template <prompt-templates>`.\n\n```bash\nllm --schema 3b7702e71da3dd791d9e17b76c88730e \\\n  --system 'extract people mentioned in this article' \\\n  --save people\n```\nThis creates a new template called \"people\". We can confirm the template was created correctly using:\n```bash\nllm templates show people\n```\nWhich will output the YAML version of the template looking like this:\n```yaml\nname: people\nschema_object:\n    properties:\n        items:\n            items:\n                properties:\n                    article_date:\n                        description: the publication date in YYYY-MM-DD\n                        type: string\n                    article_headline:\n                        description: the headline of the story\n                        type: string\n                    learned:\n                        description: what we learned about them from this story\n                        type: string\n                    name:\n                        description: the person's name\n                        type: string\n                    organization:\n                        description: who they represent\n                        type: string\n                    role:\n                        description: their job title or role\n                        type: string\n                required:\n                - name\n                - organization\n                - role\n                - learned\n                - article_headline\n                - article_date\n                type: object\n            type: array\n    required:\n    - items\n    type: object\nsystem: extract people mentioned in this article\n```\nWe can now run our people extractor against another fresh URL. Let's use one from The Guardian:\n```bash\ncurl https://www.theguardian.com/commentisfree/2025/feb/27/billy-mcfarland-new-fyre-festival-fantasist | \\\n  strip-tags | llm -t people\n```\nStoring the schema in a template means we can just use `llm -t people` to run the prompt. Here's what I got back:\n```json\n{\n  \"items\": [\n    {\n      \"name\": \"Billy McFarland\",\n      \"organization\": \"Fyre Festival\",\n      \"role\": \"Organiser\",\n      \"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.\",\n      \"article_headline\": \"Welcome back Billy McFarland and a new Fyre festival. Shows you can’t keep a good fantasist down\",\n      \"article_date\": \"2025-02-27\"\n    }\n  ]\n}\n```\nDepending on the model, schema extraction may work against images and PDF files as well.\n\nI 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:\n\n    https://static.simonwillison.net/static/2025/onion-zuck.jpg\n\nWe can pass that as an {ref}`attachment <usage-attachments>` using the `-a` option. This time let's use GPT-4o:\n\n```bash\nllm -t people -a https://static.simonwillison.net/static/2025/onion-zuck.jpg -m gpt-4o\n```\nWhich gave me back this:\n```json\n{\n  \"items\": [\n    {\n      \"name\": \"Mark Zuckerberg\",\n      \"organization\": \"Facebook\",\n      \"role\": \"CEO\",\n      \"learned\": \"He addressed criticism by suggesting anyone with similar values and thirst for power could make the same mistakes.\",\n      \"article_headline\": \"Mark Zuckerberg Insists Anyone With Same Skewed Values And Unrelenting Thirst For Power Could Have Made Same Mistakes\",\n      \"article_date\": \"2018-06-14\"\n    }\n  ]\n}\n```\nNow that we've extracted people from a number of different sources, let's load them into a database.\n\nThe {ref}`llm logs <logging-view>` 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:\n\n```bash\nllm logs --schema t:people --data-key items\n```\nIn place of `t:people` we could use the `3b7702e71da3dd791d9e17b76c88730e` schema ID or even the original schema string instead, see {ref}`specifying a schema <schemas-specify>`.\n\nThis command outputs newline-delimited JSON for every item that has been captured using the specified schema:\n```json\n{\"name\": \"Katy Perry\", \"organization\": \"Blue Origin\", \"role\": \"Singer\", \"learned\": \"She is one of the passengers on the upcoming spaceflight with Blue Origin.\"}\n{\"name\": \"Gayle King\", \"organization\": \"Blue Origin\", \"role\": \"TV Journalist\", \"learned\": \"She is participating in the upcoming Blue Origin spaceflight.\"}\n{\"name\": \"Lauren Sanchez\", \"organization\": \"Blue Origin\", \"role\": \"Helicopter Pilot and former TV Journalist\", \"learned\": \"She selected the crew for the Blue Origin spaceflight.\"}\n{\"name\": \"Aisha Bowe\", \"organization\": \"Engineering firm\", \"role\": \"Former NASA Rocket Scientist\", \"learned\": \"She is part of the crew for the spaceflight.\"}\n{\"name\": \"Amanda Nguyen\", \"organization\": \"Research Scientist\", \"role\": \"Activist and Scientist\", \"learned\": \"She is included in the crew for the upcoming Blue Origin flight.\"}\n{\"name\": \"Kerianne Flynn\", \"organization\": \"Movie Producer\", \"role\": \"Producer\", \"learned\": \"She will also be a passenger on the upcoming spaceflight.\"}\n{\"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\"}\n{\"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\"}\n```\nIf we add `--data-array` we'll get back a valid JSON array of objects instead:\n```bash\nllm logs --schema t:people --data-key items --data-array\n```\nOutput starts:\n```json\n[{\"name\": \"Katy Perry\", \"organization\": \"Blue Origin\", \"role\": \"Singer\", \"learned\": \"She is one of the passengers on the upcoming spaceflight with Blue Origin.\"},\n {\"name\": \"Gayle King\", \"organization\": \"Blue Origin\", \"role\": \"TV Journalist\", \"learned\": \"She is participating in the upcoming Blue Origin spaceflight.\"},\n```\n\nWe 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.\n\n```bash\nuv tool install sqlite-utils\n# or pip install or pipx install\n```\nNow we can pipe the JSON into that tool to create a database with a `people` table:\n```bash\nllm logs --schema t:people --data-key items --data-array | \\\n  sqlite-utils insert data.db people -\n```\nTo 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):\n```bash\nsqlite-utils rows data.db people -t -c name -c organization -c role\n```\nWhich produces:\n```\nname             organization        role\n---------------  ------------------  -----------------------------------------\nKaty Perry       Blue Origin         Singer\nGayle King       Blue Origin         TV Journalist\nLauren Sanchez   Blue Origin         Helicopter Pilot and former TV Journalist\nAisha Bowe       Engineering firm    Former NASA Rocket Scientist\nAmanda Nguyen    Research Scientist  Activist and Scientist\nKerianne Flynn   Movie Producer      Producer\nBilly McFarland  Fyre Festival       Organiser\nMark Zuckerberg  Facebook            CEO\n```\nWe can also explore the database in a web interface using [Datasette](https://datasette.io/):\n\n```bash\nuvx datasette data.db\n# Or install datasette first:\nuv tool install datasette # or pip install or pipx install\ndatasette data.db\n```\nVisit `http://127.0.0.1:8001/data/people` to start navigating the data.\n\n(schemas-json-schemas)=\n\n## Using JSON schemas\n\nThe above examples have both used {ref}`concise schema syntax <schemas-dsl>`. LLM converts this format to [JSON schema](https://json-schema.org/), and you can use JSON schema directly yourself if you wish.\n\nJSON schema covers the following:\n\n- The data types of fields (string, number, array, object, etc.)\n- Required vs. optional fields\n- Nested data structures\n- Constraints on values (minimum/maximum, patterns, etc.)\n- Descriptions of those fields - these can be used to guide the language model\n\nDifferent 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.\n\nLLM 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.\n\nThe dogs schema above, `name, age int, one_sentence_bio`, would look like this as a full JSON schema:\n\n```json\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"name\": {\n      \"type\": \"string\"\n    },\n    \"age\": {\n      \"type\": \"integer\"\n    },\n    \"one_sentence_bio\": {\n      \"type\": \"string\"\n    }\n  },\n  \"required\": [\n    \"name\",\n    \"age\",\n    \"one_sentence_bio\"\n  ]\n}\n```\nThis JSON can be passed directly to the `--schema` option, or saved in a file and passed as the filename.\n```bash\nllm --schema '{\n  \"type\": \"object\",\n  \"properties\": {\n    \"name\": {\n      \"type\": \"string\"\n    },\n    \"age\": {\n      \"type\": \"integer\"\n    },\n    \"one_sentence_bio\": {\n      \"type\": \"string\"\n    }\n  },\n  \"required\": [\n    \"name\",\n    \"age\",\n    \"one_sentence_bio\"\n  ]\n}' 'a surprising dog'\n```\nExample output:\n```json\n{\n  \"name\": \"Baxter\",\n  \"age\": 3,\n  \"one_sentence_bio\": \"Baxter is a rescue dog who learned to skateboard and now performs tricks at local parks, astonishing everyone with his skill!\"\n}\n```\n\n(schemas-specify)=\n\n## Ways to specify a schema\n\nLLM accepts schema definitions for both running prompts and exploring logged responses, using the `--schema` option.\n\nThis option can take multiple forms:\n\n- A string providing a JSON schema: `--schema '{\"type\": \"object\", ...}'`\n- A {ref}`condensed schema definition <schemas-dsl>`: `--schema 'name,age int'`\n- The name or path of a file on disk containing a JSON schema: `--schema dogs.schema.json`\n- The hexadecimal ID of a previously logged schema: `--schema 520f7aabb121afd14d0c6c237b39ba2d` - these IDs can be found using the `llm schemas` command.\n- A schema that has been {ref}`saved in a template <prompt-templates-save>`: `--schema t:name-of-template`, see {ref}`schemas-reusable`.\n\n(schemas-dsl)=\n\n## Concise LLM schema syntax\n\nJSON schema's can be time-consuming to construct by hand. LLM also supports a concise alternative syntax for specifying a schema.\n\nA simple schema for an object with two string properties called `name` and `bio` looks like this:\n\n    name, bio\n\nYou can include type information by adding a type indicator after the property name, separated by a space.\n\n    name, bio, age int\n\nSupported types are `int` for integers, `float` for floating point numbers, `str` for strings (the default) and `bool` for true/false booleans.\n\nTo include a description of the field to act as a hint to the model, add one after a colon:\n\n    name: the person's name, age int: their age, bio: a short bio\n\nIf your schema is getting long you can switch from comma-separated to newline-separated, which also allows you to use commas in those descriptions:\n\n    name: the person's name\n    age int: their age\n    bio: a short bio, no more than three sentences\n\nYou can experiment with the syntax using the `llm schemas dsl` command, which converts the input into a JSON schema:\n```bash\nllm schemas dsl 'name, age int'\n```\nOutput:\n```json\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"name\": {\n      \"type\": \"string\"\n    },\n    \"age\": {\n      \"type\": \"integer\"\n    }\n  },\n  \"required\": [\n    \"name\",\n    \"age\"\n  ]\n}\n```\n\nThe 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 <python-api-schemas>`.\n\n(schemas-reusable)=\n\n## Saving reusable schemas in templates\n\nIf 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 <prompt-templates-schemas>`.\n\nThe quickest way to do that is with the `llm --save` option:\n\n```bash\nllm --schema 'name, age int, one_sentence_bio' --save dog\n```\nNow you can use it like this:\n```bash\nllm --schema t:dog 'invent a dog'\n```\nOr:\n```bash\nllm --schema-multi t:dog 'invent three dogs'\n```\n(schemas-logs)=\n\n## Browsing logged JSON objects created using schemas\n\nBy default, all JSON produced using schemas is logged to {ref}`a SQLite database <logging>`. You can use special options to the `llm logs` command to extract just those JSON objects in a useful format.\n\nThe `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.\n\nThe `--data` option causes just the JSON data collected by that schema to be outputted, as newline-delimited JSON.\n\nIf you instead want a JSON array of objects (with starting and ending square braces) you can use `--data-array` instead.\n\nLet's invent some dogs:\n\n```bash\nllm --schema-multi 'name, ten_word_bio' 'invent 3 cool dogs'\nllm --schema-multi 'name, ten_word_bio' 'invent 2 cool dogs'\n```\nHaving logged these cool dogs, you can see just the data that was returned by those prompts like this:\n```bash\nllm logs --schema-multi 'name, ten_word_bio' --data\n```\nWe 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.\n\nOutput:\n```\n{\"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.\"}]}\n{\"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.\"}]}\n```\nNote that the dogs are nested in that `\"items\"` key. To access the list of items from that key use `--data-key items`:\n```bash\nllm logs --schema-multi 'name, ten_word_bio' --data-key items\n```\nOutput:\n```\n{\"name\": \"Bolt\", \"ten_word_bio\": \"Lightning-fast border collie, loves frisbee and outdoor adventures.\"}\n{\"name\": \"Luna\", \"ten_word_bio\": \"Mystical husky with mesmerizing blue eyes, enjoys snow and play.\"}\n{\"name\": \"Ziggy\", \"ten_word_bio\": \"Quirky pug who loves belly rubs and quirky outfits.\"}\n{\"name\": \"Robo\", \"ten_word_bio\": \"A cybernetic dog with laser eyes and super intelligence.\"}\n{\"name\": \"Flamepaw\", \"ten_word_bio\": \"Fire-resistant dog with a talent for agility and tricks.\"}\n```\nFinally, to output a JSON array instead of newline-delimited JSON use `--data-array`:\n```bash\nllm logs --schema-multi 'name, ten_word_bio' --data-key items --data-array\n```\nOutput:\n```json\n[{\"name\": \"Bolt\", \"ten_word_bio\": \"Lightning-fast border collie, loves frisbee and outdoor adventures.\"},\n {\"name\": \"Luna\", \"ten_word_bio\": \"Mystical husky with mesmerizing blue eyes, enjoys snow and play.\"},\n {\"name\": \"Ziggy\", \"ten_word_bio\": \"Quirky pug who loves belly rubs and quirky outfits.\"},\n {\"name\": \"Robo\", \"ten_word_bio\": \"A cybernetic dog with laser eyes and super intelligence.\"},\n {\"name\": \"Flamepaw\", \"ten_word_bio\": \"Fire-resistant dog with a talent for agility and tricks.\"}]\n```\nAdd `--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.\n\n```bash\nllm logs --schema-multi 'name, ten_word_bio' --data-key items --data-ids\n```\nOutput:\n```json\n{\"name\": \"Nebula\", \"ten_word_bio\": \"A cosmic puppy with starry fur, loves adventures in space.\", \"response_id\": \"01jn4dawj8sq0c6t3emf4k5ryx\", \"conversation_id\": \"01jn4dawj8sq0c6t3emf4k5ryx\"}\n{\"name\": \"Echo\", \"ten_word_bio\": \"A clever hound with extraordinary hearing, master of hide-and-seek.\", \"response_id\": \"01jn4dawj8sq0c6t3emf4k5ryx\", \"conversation_id\": \"01jn4dawj8sq0c6t3emf4k5ryx\"}\n{\"name\": \"Biscuit\", \"ten_word_bio\": \"An adorable chef dog, bakes treats that everyone loves.\", \"response_id\": \"01jn4dawj8sq0c6t3emf4k5ryx\", \"conversation_id\": \"01jn4dawj8sq0c6t3emf4k5ryx\"}\n{\"name\": \"Cosmo\", \"ten_word_bio\": \"Galactic explorer, loves adventures and chasing shooting stars.\", \"response_id\": \"01jn4daycb3svj0x7kvp7zrp4q\", \"conversation_id\": \"01jn4daycb3svj0x7kvp7zrp4q\"}\n{\"name\": \"Pixel\", \"ten_word_bio\": \"Tech-savvy pup, builds gadgets and loves virtual playtime.\", \"response_id\": \"01jn4daycb3svj0x7kvp7zrp4q\", \"conversation_id\": \"01jn4daycb3svj0x7kvp7zrp4q\"}\n```\nIf 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.\n\nThe `--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."
  },
  {
    "path": "docs/setup.md",
    "content": "# Setup\n\n## Installation\n\nInstall this tool using `pip`:\n```bash\npip install llm\n```\nOr using [pipx](https://pypa.github.io/pipx/):\n```bash\npipx install llm\n```\nOr using [uv](https://docs.astral.sh/uv/guides/tools/) ({ref}`more tips below <setup-uvx>`):\n```bash\nuv tool install llm\n```\nOr using [Homebrew](https://brew.sh/) (see {ref}`warning note <homebrew-warning>`):\n```bash\nbrew install llm\n```\n\n## Upgrading to the latest version\n\nIf you installed using `pip`:\n```bash\npip install -U llm\n```\nFor `pipx`:\n```bash\npipx upgrade llm\n```\nFor `uv`:\n```bash\nuv tool upgrade llm\n```\nFor Homebrew:\n```bash\nbrew upgrade llm\n```\nIf the latest version is not yet available on Homebrew you can upgrade like this instead:\n```bash\nllm install -U llm\n```\n\n(setup-uvx)=\n## Using uvx\n\nIf 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:\n\n```bash\nexport OPENAI_API_KEY='sx-...'\nuvx llm 'fun facts about skunks'\n```\nThis will install and run LLM using a temporary virtual environment.\n\nYou can use the `--with` option to add extra plugins. To use Anthropic's models, for example:\n```bash\nexport ANTHROPIC_API_KEY='...'\nuvx --with llm-anthropic llm -m claude-3.5-haiku 'fun facts about skunks'\n```\nAll 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:\n```bash\nuvx llm keys set openai\n# Paste key here\n```\n\n(homebrew-warning)=\n## A note about Homebrew and PyTorch\n\nThe 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.\n\nThis 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.\n\nYou can workaround this by manually installing PyTorch before installing `llm-sentence-transformers`:\n\n```bash\nllm install llm-python\nllm python -m pip install \\\n  --pre torch torchvision \\\n  --index-url https://download.pytorch.org/whl/nightly/cpu\nllm install llm-sentence-transformers\n```\nThis should produce a working installation of that plugin.\n\n## Installing plugins\n\n{ref}`plugins` can be used to add support for other language models, including models that can run on your own device.\n\nFor 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:\n```bash\nllm install llm-gpt4all\n```\n\n(api-keys)=\n## API key management\n\nMany LLM models require an API key. These API keys can be provided to this tool using several different mechanisms.\n\nYou can obtain an API key for OpenAI's language models from [the API keys page](https://platform.openai.com/api-keys) on their site.\n\n### Saving and using stored keys\n\nThe easiest way to store an API key is to use the `llm keys set` command:\n\n```bash\nllm keys set openai\n```\nYou will be prompted to enter the key like this:\n```\n% llm keys set openai\nEnter key:\n```\nOnce stored, this key will be automatically used for subsequent calls to the API:\n\n```bash\nllm \"Five ludicrous names for a pet lobster\"\n```\n\nYou can list the names of keys that have been set using this command:\n\n```bash\nllm keys\n```\n\nKeys 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:\n\n```bash\nllm keys path\n```\n\nOn 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`.\n\n### Passing keys using the --key option\n\nKeys can be passed directly using the `--key` option, like this:\n\n```bash\nllm \"Five names for pet weasels\" --key sk-my-key-goes-here\n```\nYou 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:\n```bash\nllm keys set personal\n```\nAnd then use it for prompts like so:\n\n```bash\nllm \"Five friendly names for a pet skunk\" --key personal\n```\n\n### Keys in environment variables\n\nKeys can also be set using an environment variable. These are different for different models.\n\nFor OpenAI models the key will be read from the `OPENAI_API_KEY` environment variable.\n\nThe environment variable will be used if no `--key` option is passed to the command and there is not a key configured in `keys.json`\n\nTo use an environment variable in place of the `keys.json` key run the prompt like this:\n```bash\nllm 'my prompt' --key $OPENAI_API_KEY\n```\n\n## Configuration\n\nYou can configure LLM in a number of different ways.\n\n(setup-default-model)=\n### Setting a custom default model\n\nThe model used when calling `llm` without the `-m/--model` option defaults to `gpt-4o-mini` - the fastest and least expensive OpenAI model.\n\nYou 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:\n\n```bash\nllm models default gpt-4o\n```\nYou can view the current model by running this:\n```\nllm models default\n```\nAny of the supported aliases for a model can be passed to this command.\n\n### Setting a custom directory location\n\nThis tool stores various files - prompt templates, stored keys, preferences, a database of logs - in a directory on your computer.\n\nOn macOS this is `~/Library/Application Support/io.datasette.llm/`.\n\nOn Linux it may be something like `~/.config/io.datasette.llm/`.\n\nYou can set a custom location for this directory by setting the `LLM_USER_PATH` environment variable:\n\n```bash\nexport LLM_USER_PATH=/path/to/my/custom/directory\n```\n### Turning SQLite logging on and off\n\nBy default, LLM will log every prompt and response you make to a SQLite database - see {ref}`logging` for more details.\n\nYou can turn this behavior off by default by running:\n```bash\nllm logs off\n```\nOr turn it back on again with:\n```\nllm logs on\n```\nRun `llm logs status` to see the current states of the setting."
  },
  {
    "path": "docs/templates.md",
    "content": "(prompt-templates)=\n# Templates\n\nA **template** can combine a prompt, system prompt, model, default model options, schema, and fragments into a single reusable unit.\n\nOnly one template can be used at a time. To compose multiple shorter pieces of prompts together consider using {ref}`fragments <fragments>` instead.\n\n(prompt-templates-save)=\n\n## Getting started with <code>--save</code>\n\nThe easiest way to create a template is using the `--save template_name` option.\n\nHere's how to create a template for summarizing text:\n\n```bash\nllm '$input - summarize this' --save summarize\n```\nPut `$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:\n```bash\nllm 'Summarize the following: ' --save summarize\n```\nYou can also create templates using system prompts:\n```bash\nllm --system 'Summarize this' --save summarize\n```\nYou can set the default model for a template using `--model`:\n\n```bash\nllm --system 'Summarize this' --model gpt-4o --save summarize\n```\nYou can also save default options:\n```bash\nllm --system 'Speak in French' -o temperature 1.8 --save wild-french\n```\nIf you want to include a literal `$` sign in your prompt, use `$$` instead:\n```bash\nllm --system 'Estimate the cost in $$ of this: $input' --save estimate\n```\nUse `--tool/-T` one or more times to add tools to the template:\n```bash\nllm -T llm_time --system 'Always include the current time in the answer' --save time\n```\nYou can also use `--functions` to add Python function code directly to the template:\n```bash\nllm --functions 'def reverse_string(s): return s[::-1]' --system 'reverse any input' --save reverse\nllm -t reverse 'Hello, world!'\n```\n\nAdd `--schema` to bake a {ref}`schema <usage-schemas>` into your template:\n\n```bash\nllm --schema dog.schema.json 'invent a dog' --save dog\n```\n\nIf you add `--extract` the setting to  {ref}`extract the first fenced code block <usage-extract-fenced-code>` will be persisted in the template.\n```bash\nllm --system 'write a Python function' --extract --save python-function\nllm -t python-function 'calculate haversine distance between two points'\n```\nIn each of these cases the template will be saved in YAML format in a dedicated directory on disk.\n\n(prompt-templates-using)=\n\n## Using a template\n\nYou can execute a named template using the `-t/--template` option:\n\n```bash\ncurl -s https://example.com/ | llm -t summarize\n```\n\nThis can be combined with the `-m` option to specify a different model:\n```bash\ncurl -s https://llm.datasette.io/en/latest/ | \\\n  llm -t summarize -m gpt-3.5-turbo-16k\n```\nTemplates can also be specified as a direct path to a YAML file on disk:\n```bash\nllm -t path/to/template.yaml 'extra prompt here'\n```\nOr as a URL to a YAML file hosted online:\n```bash\nllm -t https://raw.githubusercontent.com/simonw/llm-templates/refs/heads/main/python-app.yaml \\\n  'Python app to pick a random line from a file'\n```\nNote 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 <plugin-hooks-register-template-loaders>`.\n\n(prompt-templates-list)=\n\n## Listing available templates\n\nThis command lists all available templates:\n```bash\nllm templates\n```\nThe output looks something like this:\n```\ncmd        : system: reply with macos terminal commands only, no extra information\nglados     : system: You are GlaDOS prompt: Summarize this:\n```\n\n(prompt-templates-yaml)=\n\n## Templates as YAML files\n\nTemplates are stored as YAML files on disk.\n\nYou can edit (or create) a YAML file for a template using the `llm templates edit` command:\n```\nllm templates edit summarize\n```\nThis will open the system default editor.\n\n:::{tip}\nYou can control which editor will be used here using the `EDITOR` environment variable - for example, to use VS Code:\n```bash\nexport EDITOR=\"code -w\"\n```\nAdd that to your `~/.zshrc` or `~/.bashrc` file depending on which shell you use (`zsh` is the default on macOS since macOS Catalina in 2019).\n:::\n\nYou can create or edit template files directly in the templates directory. The location of this directory is shown by the `llm templates path` command:\n```bash\nllm templates path\n```\nExample output:\n```\n/Users/simon/Library/Application Support/io.datasette.llm/templates\n```\n\nA basic YAML template looks like this:\n\n```yaml\nprompt: 'Summarize this: $input'\n```\nOr use YAML multi-line strings for longer inputs. I created this using `llm templates edit steampunk`:\n```yaml\nprompt: >\n    Summarize the following text.\n\n    Insert frequent satirical steampunk-themed illustrative anecdotes.\n    Really go wild with that.\n\n    Text to summarize: $input\n```\nThe `prompt: >` causes the following indented text to be treated as a single string, with newlines collapsed to spaces. Use `prompt: |` to preserve newlines.\n\nRunning 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):\n```bash\ncurl -s 'https://til.simonwillison.net/macos/imovie-slides-and-audio' | \\\n  strip-tags -m | llm -t steampunk -m gpt-4o\n```\nOutput:\n> 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.\n\n(prompt-templates-system)=\n\n### System prompts\n\nWhen working with models that support system prompts you can set a system prompt using a `system:` key like so:\n\n```yaml\nsystem: Summarize this\n```\nIf 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.\n\nYou can combine system and regular prompts like so:\n\n```yaml\nsystem: You speak like an excitable Victorian adventurer\nprompt: 'Summarize this: $input'\n```\n\n(prompt-templates-fragments)=\n\n### Fragments\n\nTemplates can reference {ref}`Fragments <fragments>` using the `fragments:` and `system_fragments:` keys. These should be a list of fragment URLs, filepaths or hashes:\n\n```yaml\nfragments:\n- https://example.com/robots.txt\n- /path/to/file.txt\n- 993fd38d898d2b59fd2d16c811da5bdac658faa34f0f4d411edde7c17ebb0680\nsystem_fragments:\n- https://example.com/systm-prompt.txt\n```\n\n(prompt-templates-options)=\n\n### Options\n\nDefault options can be set using the `options:` key:\n\n```yaml\nname: wild-french\nsystem: Speak in French\noptions:\n  temperature: 1.8\n```\n\n(prompt-templates-tools)=\n\n### Tools\n\nThe `tools:` key can provide a list of tool names from other plugins - either function names or toolbox specifiers:\n```yaml\nname: time-plus\ntools:\n- llm_time\n- Datasette(\"https://example.com/timezone-lookup\")\n```\nThe `functions:` key can provide a multi-line string of Python code defining additional functions:\n```yaml\nname: my-functions\nfunctions: |\n  def reverse_string(s: str):\n      return s[::-1]\n\n  def greet(name: str):\n      return f\"Hello, {name}!\"\n```\n(prompt-templates-schemas)=\n\n### Schemas\n\nUse 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:\n\n```yaml\nname: dogs\nschema_object:\n    properties:\n        dogs:\n            items:\n                properties:\n                    bio:\n                        type: string\n                    name:\n                        type: string\n                type: object\n            type: array\n    type: object\n```\n\n(prompt-templates-variables)=\n\n### Additional template variables\n\nTemplates 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.\n\nYou can use additional named variables. These will then need to be provided using the `-p/--param` option when executing the template.\n\nHere's an example YAML template called `recipe`, which you can create using `llm templates edit recipe`:\n\n```yaml\nprompt: |\n    Suggest a recipe using ingredients: $ingredients\n\n    It should be based on cuisine from this country: $country\n```\nThis can be executed like so:\n\n```bash\nllm -t recipe -p ingredients 'sausages, milk' -p country Germany\n```\nMy output started like this:\n> Recipe: German Sausage and Potato Soup\n>\n> Ingredients:\n> - 4 German sausages\n> - 2 cups whole milk\n\nThis example combines input piped to the tool with additional parameters. Call this `summarize`:\n\n```yaml\nsystem: Summarize this text in the voice of $voice\n```\nThen to run it:\n```bash\ncurl -s 'https://til.simonwillison.net/macos/imovie-slides-and-audio' | \\\n  strip-tags -m | llm -t summarize -p voice GlaDOS\n```\nI got this:\n\n> My previous test subject seemed to have learned something new about iMovie. They exported keynote slides as individual images [...] Quite impressive for a human.\n\n(prompt-default-parameters)=\n\n### Specifying default parameters\n\nWhen creating a template using the `--save` option you can pass `-p name value` to store the default values for parameters:\n```bash\nllm --system 'Summarize this text in the voice of $voice' \\\n  --model gpt-4o -p voice GlaDOS --save summarize\n```\n\nYou can specify default values for parameters in the YAML using the `defaults:` key.\n\n```yaml\nsystem: Summarize this text in the voice of $voice\ndefaults:\n  voice: GlaDOS\n```\n\nWhen running without `-p` it will choose the default:\n\n```bash\ncurl -s 'https://til.simonwillison.net/macos/imovie-slides-and-audio' | \\\n  strip-tags -m | llm -t summarize\n```\n\nBut you can override the defaults with `-p`:\n\n```bash\ncurl -s 'https://til.simonwillison.net/macos/imovie-slides-and-audio' | \\\n  strip-tags -m | llm -t summarize -p voice Yoda\n```\n\nI got this:\n\n> Text, summarize in Yoda's voice, I will: \"Hmm, young padawan. Summary of this text, you seek. Hmmm. ...\n\n(prompt-templates-extract)=\n\n### Configuring code extraction\n\nTo configure the {ref}`extract first fenced code block <usage-extract-fenced-code>` setting for the template, add this:\n\n```yaml\nextract: true\n```\n\n(prompt-templates-default-model)=\n\n### Setting a default model for a template\n\nTemplates 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.\n\nYou can specify a new default model for a template using the `model:` key in the associated YAML. Here's a template called `roast`:\n\n```yaml\nmodel: gpt-4o\nsystem: roast the user at every possible opportunity, be succinct\n```\nExample:\n```bash\nllm -t roast 'How are you today?'\n```\n> I'm doing great but with your boring questions, I must admit, I've seen more life in a cemetery.\n\n(prompt-templates-loaders)=\n\n## Template loaders from plugins\n\nLLM plugins can {ref}`register prefixes <plugin-hooks-register-template-loaders>` that can be used to load templates from external sources.\n\n[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.\n\nYou can install that plugin like this:\n```bash\nllm install llm-templates-github\n```\n\nUse the `llm templates loaders` command to see details of the registered loaders.\n\n```bash\nllm templates loaders\n```\nOutput:\n```\ngh:\n  Load a template from GitHub or local cache if available\n\n  Format: username/repo/template_name (without the .yaml extension)\n    or username/template_name which means username/llm-templates/template_name\n```\n\nThen you can then use it like this:\n```bash\ncurl -sL 'https://llm.datasette.io/' | llm -t gh:simonw/summarize\n```\nThe `-sL` flags to `curl` are used to follow redirects and suppress progress meters.\n\nThis 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.\n\nIf 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."
  },
  {
    "path": "docs/tools.md",
    "content": "(tools)=\n\n# Tools\n\nMany 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.\n\nExposing tools to LLMs **carries risks**! Be sure to read the {ref}`warning below <tools-warning>`.\n\n(tools-how-they-work)=\n\n## How tools work\n\nA tool is effectively a function that the model can request to be executed. Here's how that works:\n\n1. The initial prompt to the model includes a list of available tools, containing their names, descriptions and parameters.\n2. The model can choose to call one (or sometimes more than one) of those tools, returning a request for the tool to execute.\n3. The code that calls the model - in this case LLM itself - then executes the specified tool with the provided arguments.\n4. LLM prompts the model a second time, this time including the output of the tool execution.\n5. The model can then use that output to generate its next response.\n\nThis 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.\n\n:::{admonition} Tools can be dangerous\n:class: danger\n\n(tools-warning)=\n\n## Warning: Tools can be dangerous\n\nApplications 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.\n\nBe 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.\n\nWatch 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:\n\n- access to private data\n- exposure to malicious instructions\n- the ability to exfiltrate information\n\nAnyone 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.\n:::\n\n(tools-trying-out)=\n\n## Trying out tools\n\nLLM comes with a default tool installed, called `llm_version`. You can try that out like this:\n\n```bash\nllm --tool llm_version \"What version of LLM is this?\" --td\n```\nYou can also use `-T llm_version` as a shortcut for `--tool llm_version`.\n\nThe output should look like this:\n```\nTool call: llm_version({})\n  0.26a0\n\nThe installed version of the LLM is 0.26a0.\n```\nFurther 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 <usage-tools>`.\n\n(tools-implementation)=\n\n## LLM's implementation of tools\n\nIn LLM every tool is a 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.\n\nTool functions should include a docstring that describes what the function does. This docstring will become the description that is passed to the model.\n\nTools can also be defined as {ref}`toolbox classes <python-api-toolbox>`, a subclass of `llm.Toolbox` that allows multiple related tools to be bundled together. Toolbox classes can be be configured when they are instantiated, and can also maintain state in between multiple tool calls.\n\nThe 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 <plugin-hooks-register-tools>`, 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.\n\nYou can use tools {ref}`with the LLM command-line tool <usage-tools>` or {ref}`with the Python API <python-api-tools>`.\n\n(tools-default)=\n\n## Default tools\n\nLLM includes some default tools for you to try out:\n\n- `llm_version()` returns the current version of LLM\n- `llm_time()` returns the current local and UTC time\n\nTry them like this:\n\n```bash\nllm -T llm_version -T llm_time 'Give me the current time and LLM version' --td\n```\n\n(tools-tips)=\n\n## Tips for implementing tools\n\nConsult the {ref}`register_tools() plugin hook <plugin-hooks-register-tools>` documentation for examples of how to implement tools in plugins.\n\nIf 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.\n\n<!-- Uncomment when this is true: The [llm-tools-datasette](https://github.com/simonw/llm-tools-datasette) plugin is a good example of this pattern in action. -->\n"
  },
  {
    "path": "docs/usage.md",
    "content": "(usage)=\n# Usage\n\nThe 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.\n\n(usage-executing-prompts)=\n## Executing a prompt\n\nThese examples use the default OpenAI `gpt-4o-mini` model, which requires you to first {ref}`set an OpenAI API key <api-keys>`.\n\nYou can {ref}`install LLM plugins <installing-plugins>` to use models from other providers, including openly licensed models you can run directly on your own computer.\n\nTo run a prompt, streaming tokens as they come in:\n```bash\nllm 'Ten names for cheesecakes'\n```\nTo disable streaming and only return the response once it has completed:\n```bash\nllm 'Ten names for cheesecakes' --no-stream\n```\nTo switch from ChatGPT 4o-mini (the default) to GPT-4o:\n```bash\nllm 'Ten names for cheesecakes' -m gpt-4o\n```\nYou can use `-m 4o` as an even shorter shortcut.\n\nPass `--model <model name>` to use a different model. Run `llm models` to see a list of available models.\n\nOr 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:\n```bash\nllm 'Ten names for cheesecakes' -q 4o -q mini\n```\nTo change the default model for the current session, set the `LLM_MODEL` environment variable:\n```bash\nexport LLM_MODEL=gpt-4.1-mini\nllm 'Ten names for cheesecakes' # Uses gpt-4.1-mini\n```\n\nYou can send a prompt directly to standard input like this:\n```bash\necho 'Ten names for cheesecakes' | llm\n```\nIf you send text to standard input and provide arguments, the resulting prompt will consist of the piped content followed by the arguments:\n```bash\ncat myscript.py | llm 'explain this code'\n```\nWill run a prompt of:\n```\n<contents of myscript.py> explain this code\n```\nFor models that support them, {ref}`system prompts <usage-system-prompts>` are a better tool for this kind of prompting.\n\n(usage-model-options)=\n### Model options\n\nSome models support options. You can pass these using `-o/--option name value` - for example, to set the temperature to 1.5 run this:\n\n```bash\nllm 'Ten names for cheesecakes' -o temperature 1.5\n```\n\nUse the `llm models --options` command to see which options are supported by each model.\n\nYou can also {ref}`configure default options <usage-executing-default-options>` for a model using the `llm models options` commands.\n\n(usage-attachments)=\n### Attachments\n\nSome 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.\n\nLLM calls these **attachments**. You can pass attachments using the `-a` option like this:\n\n```bash\nllm \"describe this image\" -a https://static.simonwillison.net/static/2024/pelicans.jpg\n```\nAttachments can be passed using URLs or file paths, and you can attach more than one attachment to a single prompt:\n```bash\nllm \"extract text\" -a image1.jpg -a image2.jpg\n```\nYou can also pipe an attachment to LLM by using `-` as the filename:\n```bash\ncat image.jpg | llm \"describe this image\" -a -\n```\nLLM 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:\n```bash\ncat myfile | llm \"describe this image\" --at - image/jpeg\n```\n\n(usage-system-prompts)=\n### System prompts\n\nYou can use `-s/--system '...'` to set a system prompt.\n```bash\nllm 'SQL to calculate total sales by month' \\\n  --system 'You are an exaggerated sentient cheesecake that knows SQL and talks about cheesecake a lot'\n```\nThis is useful for piping content to standard input, for example:\n```bash\ncurl -s 'https://simonwillison.net/2023/May/15/per-interpreter-gils/' | \\\n  llm -s 'Suggest topics for this post as a JSON array'\n```\nOr to generate a description of changes made to a Git repository since the last commit:\n```bash\ngit diff | llm -s 'Describe these changes'\n```\nDifferent models support system prompts in different ways.\n\nThe 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.\n\nOther models might use system prompts change the default voice and attitude of the model.\n\nSystem prompts can be saved as {ref}`templates <prompt-templates>` to create reusable tools. For example, you can create a template called `pytest` like this:\n\n```bash\nllm -s 'write pytest tests for this code' --save pytest\n```\nAnd then use the new template like this:\n```bash\ncat llm/utils.py | llm -t pytest\n```\nSee {ref}`prompt templates <prompt-templates>` for more.\n\n(usage-tools)=\n### Tools\n\nMany models support the ability to call {ref}`external tools <tools>`. Tools can be provided {ref}`by plugins <plugin-hooks-register-tools>` or you can pass a `--functions CODE` option to LLM to define one or more Python functions that the model can then call.\n\n```bash\nllm --functions '\ndef multiply(x: int, y: int) -> int:\n    \"\"\"Multiply two numbers.\"\"\"\n    return x * y\n' 'what is 34234 * 213345'\n```\nAdd `--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.\n```bash\nllm --functions '\ndef multiply(x: int, y: int) -> int:\n    \"\"\"Multiply two numbers.\"\"\"\n    return x * y\n' 'what is 34234 * 213345' --td\n```\nOutput:\n```\nTool call: multiply({'x': 34234, 'y': 213345})\n  7303652730\n34234 multiplied by 213345 is 7,303,652,730.\n```\nOr add `--ta/--tools-approve` to approve each tool call interactively before it is executed:\n\n```bash\nllm --functions '\ndef multiply(x: int, y: int) -> int:\n    \"\"\"Multiply two numbers.\"\"\"\n    return x * y\n' 'what is 34234 * 213345' --ta\n```\nOutput:\n```\nTool call: multiply({'x': 34234, 'y': 213345})\nApprove tool call? [y/N]:\n```\nThe `--functions` option can be passed more than once, and can also point to the filename of a `.py` file containing one or more functions.\n\nIf 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:\n\n```bash\nllm install llm-tools-simpleeval\nllm --tool simple_eval \"4444 * 233423\" --td\n```\nRun this command to see a list of available tools from plugins:\n```bash\nllm tools\n```\nIf 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:\n\n```\nllm -T simple_eval \"12345 * 12345\" --td\nTool call: simple_eval({'expression': '12345 * 12345'})\n  152399025\n12345 multiplied by 12345 equals 152,399,025.\nllm -c \"that * 6\" --td\nTool call: simple_eval({'expression': '152399025 * 6'})\n  914394150\n152,399,025 multiplied by 6 equals 914,394,150.\nllm chat -c --td\nChatting with gpt-4.1-mini\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\n> / 123\nTool call: simple_eval({'expression': '914394150 / 123'})\n  7434098.780487805\n914,394,150 divided by 123 is approximately 7,434,098.78.\n```\nSome tools are bundled in a configurable collection of tools called a **toolbox**. This means a single `--tool` option can load multiple related tools.\n\n[llm-tools-datasette](https://github.com/simonw/llm-tools-datasette) is one example. Using a toolbox looks like this:\n\n```bash\nllm install llm-tools-datasette\nllm -T 'Datasette(\"https://datasette.io/content\")' \"Show tables\" --td\n```\nToolboxes always start with a capital letter. They can be configured by passing a tool specification, which should fit the following patterns:\n\n- Empty: `ToolboxName` or `ToolboxName()` - has no configuration arguments\n- JSON object: `ToolboxName({\"key\": \"value\", \"other\": 42})`\n- Single JSON value: `ToolboxName(\"hello\")` or `ToolboxName([1,2,3])`\n- 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\n\nToolboxes 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:\n\n```bash\nllm chat -T 'Datasette(\"https://datasette.io/content\")' --td\n```\n```\nChatting with gpt-4.1-mini\nType 'exit' or 'quit' to exit\n...\n> show tables\n```\n\n(usage-extract-fenced-code)=\n### Extracting fenced code blocks\n\nIf 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.\n\nThe `-x/--extract` option will scan the response for the first instance of a Markdown fenced code block - something that looks like this:\n\n````\n```python\ndef my_function():\n    # ...\n```\n````\nIt 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.\n\nUse `--xl/--extract-last` to return the last fenced code block instead of the first.\n\nThe entire response including explanatory text is still logged to the database, and can be viewed using `llm logs -c`.\n\n(usage-schemas)=\n### Schemas\n\nSome 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.\n\nTake a look at the {ref}`schemas documentation <schemas>` for a detailed guide to using this feature.\n\nYou can pass JSON schemas directly to the `--schema` option:\n\n```bash\nllm --schema '{\n  \"type\": \"object\",\n  \"properties\": {\n    \"dogs\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"bio\": {\n            \"type\": \"string\"\n          }\n        }\n      }\n    }\n  }\n}' -m gpt-4o-mini 'invent two dogs'\n```\n\nOr use LLM's custom {ref}`concise schema syntax <schemas-dsl>` like this:\n```bash\nllm --schema 'name,bio' 'invent a dog'\n```\nTwo use the same concise schema for multiple items use `--schema-multi`:\n```bash\nllm --schema-multi 'name,bio' 'invent two dogs'\n```\nYou can also save the JSON schema to a file and reference the filename using `--schema`:\n\n```bash\nllm --schema dogs.schema.json 'invent two dogs'\n```\n\nOr save your schema {ref}`to a template <prompt-templates>` like this:\n\n```bash\nllm --schema dogs.schema.json --save dogs\n# Then to use it:\nllm -t dogs 'invent two dogs'\n```\n\nBe warned that different models may support different dialects of the JSON schema specification.\n\nSee {ref}`schemas-logs` for tips on using the `llm logs --schema X` command to access JSON objects you have previously logged using this option.\n\n(usage-fragments)=\n### Fragments\n\nYou 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.\n\nFragments are designed for running longer prompts. LLM {ref}`stores prompts in a database <logging>`, 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.\n\nThe `-f` option can accept a path to a file on disk, a URL or the hash or alias of a previous fragment.\n\nFor example, to ask a question about the `robots.txt` file on `llm.datasette.io`:\n```bash\nllm -f https://llm.datasette.io/robots.txt 'explain this'\n```\nFor a poem inspired by some Python code on disk:\n```bash\nllm -f cli.py 'a short snappy poem inspired by this code'\n```\nYou 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.\n\nFragments 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:\n\n```\nExplain this code in detail. Include copies of the code quoted in the explanation.\n```\nYou can run it as the system prompt like this:\n```bash\nllm -f cli.py --sf explain_code.txt\n```\n\nYou can use the `llm fragments set` command to load a fragment and give it an alias for use in future queries:\n```bash\nllm fragments set cli cli.py\n# Then\nllm -f cli 'explain this code'\n```\nUse `llm fragments` to list all fragments that have been stored:\n```bash\nllm fragments\n```\nYou 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:\n```bash\nllm fragments -q pytest -q asyncio\n```\n\nThe `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.\n```bash\nllm fragments remove cli\n```\n\n(usage-conversation)=\n### Continuing a conversation\n\nBy default, the tool will start a new conversation each time you run it.\n\nYou can opt to continue the previous conversation by passing the `-c/--continue` option:\n```bash\nllm 'More names' -c\n```\nThis 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.\n\n`--continue` will automatically use the same model as the conversation that you are continuing, even if you omit the `-m/--model` option.\n\nTo continue a conversation that is not the most recent one, use the `--cid/--conversation <id>` option:\n```bash\nllm 'More names' --cid 01h53zma5txeby33t1kbe3xk8q\n```\nYou can find these conversation IDs using the `llm logs` command.\n\n### Tips for using LLM with Bash or Zsh\n\nTo learn more about your computer's operating system based on the output of `uname -a`, run this:\n```bash\nllm \"Tell me about my operating system: $(uname -a)\"\n```\nThis pattern of using `$(command)` inside a double quoted string is a useful way to quickly assemble prompts.\n\n(usage-completion-prompts)=\n### Completion prompts\n\nSome models are completion models - rather than being tuned to respond to chat style prompts, they are designed to complete a sentence or paragraph.\n\nAn example of this is the `gpt-3.5-turbo-instruct` OpenAI model.\n\nYou 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.\n\n```bash\nllm -m gpt-3.5-turbo-instruct 'Reasons to tame a wild beaver:'\n```\n\n(usage-chat)=\n\n## Starting an interactive chat\n\nThe `llm chat` command starts an ongoing interactive chat with a model.\n\nThis 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.\n\nRun `llm chat`, optionally with a `-m model_id`, to start a chat conversation:\n\n```bash\nllm chat -m chatgpt\n```\nEach chat starts a new conversation. A record of each conversation can be accessed through {ref}`the logs <logging-conversation>`.\n\nYou can pass `-c` to start a conversation as a continuation of your most recent prompt. This will automatically use the most recently used model:\n\n```bash\nllm chat -c\n```\n\nFor models that support them, you can pass options using `-o/--option`:\n```bash\nllm chat -m gpt-4 -o temperature 0.5\n```\n\nYou can pass a system prompt to be used for your chat conversation:\n\n```bash\nllm chat -m gpt-4 -s 'You are a sentient cheesecake'\n```\nYou can also pass {ref}`a template <prompt-templates>` - useful for creating chat personas that you wish to return to.\n\nHere's how to create a template for your GPT-4 powered cheesecake:\n```bash\nllm --system 'You are a sentient cheesecake' -m gpt-4 --save cheesecake\n```\nNow you can start a new chat with your cheesecake any time you like using this:\n```bash\nllm chat -t cheesecake\n```\n```\nChatting with gpt-4\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 <my_fragment> [<another_fragment> ...]' to insert one or more fragments\n> who are you?\nI am a sentient cheesecake, meaning I am an artificial\nintelligence embodied in a dessert form, specifically a\ncheesecake. However, I don't consume or prepare foods\nlike humans do, I communicate, learn and help answer\nyour queries.\n```\n\nType `quit` or `exit` followed by `<enter>` to end a chat session.\n\nSometimes you may want to paste multiple lines of text into a chat at once - for example when debugging an error message.\n\nTo do that, type `!multi` to start a multi-line input. Type or paste your text, then type `!end` and hit `<enter>` to finish.\n\nIf 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:\n\n```\nChatting with gpt-4\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 <my_fragment> [<another_fragment> ...]' to insert one or more fragments\n> !multi custom-end\n Explain this error:\n\n   File \"/opt/homebrew/Caskroom/miniconda/base/lib/python3.10/urllib/request.py\", line 1391, in https_open\n    return self.do_open(http.client.HTTPSConnection, req,\n  File \"/opt/homebrew/Caskroom/miniconda/base/lib/python3.10/urllib/request.py\", line 1351, in do_open\n    raise URLError(err)\nurllib.error.URLError: <urlopen error [Errno 8] nodename nor servname provided, or not known>\n\n !end custom-end\n```\n\nYou can also use `!edit` to open your default editor and modify the prompt before sending it to the model.\n\n```\nChatting with gpt-4\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 <my_fragment> [<another_fragment> ...]' to insert one or more fragments\n> !edit\n```\n\n`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 <usage-tools>` enabled.\n\n## Listing available models\n\nThe `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 <plugins>`.\n\n```bash\nllm models\n```\nExample output:\n```\nOpenAI Chat: gpt-4o (aliases: 4o)\nOpenAI Chat: gpt-4o-mini (aliases: 4o-mini)\nOpenAI Chat: o1-preview\nOpenAI Chat: o1-mini\nGeminiPro: gemini-1.5-pro-002\nGeminiPro: gemini-1.5-flash-002\n...\n```\n\nAdd one or more `-q term` options to search for models matching all of those search terms:\n```bash\nllm models -q gpt-4o\nllm models -q 4o -q mini\n```\nUse one or more `-m` options to indicate specific models, either by their model ID or one of their aliases:\n```bash\nllm models -m gpt-4o -m gemini-1.5-pro-002\n```\nAdd `--options` to also see documentation for the options supported by each model:\n```bash\nllm models --options\n```\nOutput:\n<!-- [[[cog\nfrom click.testing import CliRunner\nfrom llm.cli import cli\nresult = CliRunner().invoke(cli, [\"models\", \"list\", \"--options\"])\ncog.out(\"```\\n{}\\n```\".format(result.output))\n]]] -->\n```\nOpenAI Chat: gpt-4o (aliases: 4o)\n  Options:\n    temperature: float\n      What sampling temperature to use, between 0 and 2. Higher values like\n      0.8 will make the output more random, while lower values like 0.2 will\n      make it more focused and deterministic.\n    max_tokens: int\n      Maximum number of tokens to generate.\n    top_p: float\n      An alternative to sampling with temperature, called nucleus sampling,\n      where the model considers the results of the tokens with top_p\n      probability mass. So 0.1 means only the tokens comprising the top 10%\n      probability mass are considered. Recommended to use top_p or\n      temperature but not both.\n    frequency_penalty: float\n      Number between -2.0 and 2.0. Positive values penalize new tokens based\n      on their existing frequency in the text so far, decreasing the model's\n      likelihood to repeat the same line verbatim.\n    presence_penalty: float\n      Number between -2.0 and 2.0. Positive values penalize new tokens based\n      on whether they appear in the text so far, increasing the model's\n      likelihood to talk about new topics.\n    stop: str\n      A string where the API will stop generating further tokens.\n    logit_bias: dict, str\n      Modify the likelihood of specified tokens appearing in the completion.\n      Pass a JSON string like '{\"1712\":-100, \"892\":-100, \"1489\":-100}'\n    seed: int\n      Integer seed to attempt to sample deterministically\n    json_object: boolean\n      Output a valid JSON object {...}. Prompt must mention JSON.\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: chatgpt-4o-latest (aliases: chatgpt-4o)\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4o-mini (aliases: 4o-mini)\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4o-audio-preview\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Attachment types:\n    audio/mpeg, audio/wav\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4o-audio-preview-2024-12-17\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Attachment types:\n    audio/mpeg, audio/wav\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4o-audio-preview-2024-10-01\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Attachment types:\n    audio/mpeg, audio/wav\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4o-mini-audio-preview\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Attachment types:\n    audio/mpeg, audio/wav\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4o-mini-audio-preview-2024-12-17\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Attachment types:\n    audio/mpeg, audio/wav\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4.1 (aliases: 4.1)\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4.1-mini (aliases: 4.1-mini)\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4.1-nano (aliases: 4.1-nano)\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-3.5-turbo (aliases: 3.5, chatgpt)\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-3.5-turbo-16k (aliases: chatgpt-16k, 3.5-16k)\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4 (aliases: 4, gpt4)\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4-32k (aliases: 4-32k)\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4-1106-preview\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4-0125-preview\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4-turbo-2024-04-09\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4-turbo (aliases: gpt-4-turbo-preview, 4-turbo, 4t)\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4.5-preview-2025-02-27\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-4.5-preview (aliases: gpt-4.5)\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: o1\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: o1-2024-12-17\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: o1-preview\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: o1-mini\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n  Features:\n  - streaming\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: o3-mini\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: o3\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: o4-mini\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5-mini\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5-nano\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5-2025-08-07\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5-mini-2025-08-07\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5-nano-2025-08-07\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5.1\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5.1-chat-latest\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5.2\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5.2-chat-latest\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5.4\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5.4-2026-03-05\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5.4-mini\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5.4-mini-2026-03-17\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5.4-nano\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Chat: gpt-5.4-nano-2026-03-17\n  Options:\n    temperature: float\n    max_tokens: int\n    top_p: float\n    frequency_penalty: float\n    presence_penalty: float\n    stop: str\n    logit_bias: dict, str\n    seed: int\n    json_object: boolean\n    reasoning_effort: str\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Features:\n  - streaming\n  - schemas\n  - tools\n  - async\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\nOpenAI Completion: gpt-3.5-turbo-instruct (aliases: 3.5-instruct, chatgpt-instruct)\n  Options:\n    temperature: float\n      What sampling temperature to use, between 0 and 2. Higher values like\n      0.8 will make the output more random, while lower values like 0.2 will\n      make it more focused and deterministic.\n    max_tokens: int\n      Maximum number of tokens to generate.\n    top_p: float\n      An alternative to sampling with temperature, called nucleus sampling,\n      where the model considers the results of the tokens with top_p\n      probability mass. So 0.1 means only the tokens comprising the top 10%\n      probability mass are considered. Recommended to use top_p or\n      temperature but not both.\n    frequency_penalty: float\n      Number between -2.0 and 2.0. Positive values penalize new tokens based\n      on their existing frequency in the text so far, decreasing the model's\n      likelihood to repeat the same line verbatim.\n    presence_penalty: float\n      Number between -2.0 and 2.0. Positive values penalize new tokens based\n      on whether they appear in the text so far, increasing the model's\n      likelihood to talk about new topics.\n    stop: str\n      A string where the API will stop generating further tokens.\n    logit_bias: dict, str\n      Modify the likelihood of specified tokens appearing in the completion.\n      Pass a JSON string like '{\"1712\":-100, \"892\":-100, \"1489\":-100}'\n    seed: int\n      Integer seed to attempt to sample deterministically\n    logprobs: int\n      Include the log probabilities of most likely N per token\n  Features:\n  - streaming\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\n\n```\n<!-- [[[end]]] -->\n\nWhen running a prompt you can pass the full model name or any of the aliases to the `-m/--model` option:\n```bash\nllm -m 4o \\\n  'As many names for cheesecakes as you can think of, with detailed descriptions'\n```\n\n(usage-executing-default-options)=\n\n## Setting default options for models\n\nTo configure a default option for a specific model, use the `llm models options set` command:\n```bash\nllm models options set gpt-4o temperature 0.5\n```\nThis option will then be applied automatically any time you run a prompt through the `gpt-4o` model.\n\nDefault options are stored in the `model_options.json` file in the LLM configuration directory.\n\nYou can list all default options across all models using the `llm models options list` command:\n```bash\nllm models options list\n```\nOr show them for an individual model with `llm models options show <model_id>`:\n```bash\nllm models options show gpt-4o\n```\nTo clear a default option, use the `llm models options clear` command:\n```bash\nllm models options clear gpt-4o temperature\n```\nOr clear all default options for a model like this:\n```bash\nllm models options clear gpt-4o\n```\nDefault 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 <python-api>`.\n"
  },
  {
    "path": "llm/__init__.py",
    "content": "from .hookspecs import hookimpl\nfrom .errors import (\n    ModelError,\n    NeedsKeyException,\n)\nfrom .models import (\n    AsyncConversation,\n    AsyncKeyModel,\n    AsyncModel,\n    AsyncResponse,\n    Attachment,\n    CancelToolCall,\n    Conversation,\n    EmbeddingModel,\n    EmbeddingModelWithAliases,\n    KeyModel,\n    Model,\n    ModelWithAliases,\n    Options,\n    Prompt,\n    Response,\n    Tool,\n    Toolbox,\n    ToolCall,\n    ToolOutput,\n    ToolResult,\n)\nfrom .utils import schema_dsl, Fragment\nfrom .embeddings import Collection\nfrom .templates import Template\nfrom .plugins import pm, load_plugins\nimport click\nfrom typing import Any, Dict, List, Optional, Callable, Type, Union\nimport inspect\nimport json\nimport os\nimport pathlib\nimport struct\n\n__all__ = [\n    \"AsyncConversation\",\n    \"AsyncKeyModel\",\n    \"AsyncModel\",\n    \"AsyncResponse\",\n    \"Attachment\",\n    \"CancelToolCall\",\n    \"Collection\",\n    \"Conversation\",\n    \"Fragment\",\n    \"get_async_model\",\n    \"get_key\",\n    \"get_model\",\n    \"hookimpl\",\n    \"KeyModel\",\n    \"Model\",\n    \"ModelError\",\n    \"NeedsKeyException\",\n    \"Options\",\n    \"Prompt\",\n    \"Response\",\n    \"Template\",\n    \"Tool\",\n    \"Toolbox\",\n    \"ToolCall\",\n    \"ToolOutput\",\n    \"ToolResult\",\n    \"user_dir\",\n    \"schema_dsl\",\n]\nDEFAULT_MODEL = \"gpt-4o-mini\"\n\n\ndef get_plugins(all=False):\n    plugins = []\n    plugin_to_distinfo = dict(pm.list_plugin_distinfo())\n    for plugin in pm.get_plugins():\n        if not all and plugin.__name__.startswith(\"llm.default_plugins.\"):\n            continue\n        plugin_info = {\n            \"name\": plugin.__name__,\n            \"hooks\": [h.name for h in pm.get_hookcallers(plugin)],\n        }\n        distinfo = plugin_to_distinfo.get(plugin)\n        if distinfo:\n            plugin_info[\"version\"] = distinfo.version\n            plugin_info[\"name\"] = (\n                getattr(distinfo, \"name\", None) or distinfo.project_name\n            )\n        plugins.append(plugin_info)\n    return plugins\n\n\ndef get_models_with_aliases() -> List[\"ModelWithAliases\"]:\n    model_aliases = []\n\n    # Include aliases from aliases.json\n    aliases_path = user_dir() / \"aliases.json\"\n    extra_model_aliases: Dict[str, list] = {}\n    if aliases_path.exists():\n        configured_aliases = json.loads(aliases_path.read_text())\n        for alias, model_id in configured_aliases.items():\n            extra_model_aliases.setdefault(model_id, []).append(alias)\n\n    def register(model, async_model=None, aliases=None):\n        alias_list = list(aliases or [])\n        if model.model_id in extra_model_aliases:\n            alias_list.extend(extra_model_aliases[model.model_id])\n        model_aliases.append(ModelWithAliases(model, async_model, alias_list))\n\n    load_plugins()\n    pm.hook.register_models(register=register)\n\n    return model_aliases\n\n\ndef _get_loaders(hook_method) -> Dict[str, Callable]:\n    load_plugins()\n    loaders = {}\n\n    def register(prefix, loader):\n        suffix = 0\n        prefix_to_try = prefix\n        while prefix_to_try in loaders:\n            suffix += 1\n            prefix_to_try = f\"{prefix}_{suffix}\"\n        loaders[prefix_to_try] = loader\n\n    hook_method(register=register)\n    return loaders\n\n\ndef get_template_loaders() -> Dict[str, Callable[[str], Template]]:\n    \"\"\"Get template loaders registered by plugins.\"\"\"\n    return _get_loaders(pm.hook.register_template_loaders)\n\n\ndef get_fragment_loaders() -> Dict[\n    str,\n    Callable[[str], Union[Fragment, Attachment, List[Union[Fragment, Attachment]]]],\n]:\n    \"\"\"Get fragment loaders registered by plugins.\"\"\"\n    return _get_loaders(pm.hook.register_fragment_loaders)\n\n\ndef get_tools() -> Dict[str, Union[Tool, Type[Toolbox]]]:\n    \"\"\"Return all tools (llm.Tool and llm.Toolbox) registered by plugins.\"\"\"\n    load_plugins()\n    tools: Dict[str, Union[Tool, Type[Toolbox]]] = {}\n\n    # Variable to track current plugin name\n    current_plugin_name = None\n\n    def register(\n        tool_or_function: Union[Tool, Type[Toolbox], Callable[..., Any]],\n        name: Optional[str] = None,\n    ) -> None:\n        tool: Union[Tool, Type[Toolbox], None] = None\n\n        # If it's a Toolbox class, set the plugin field on it\n        if inspect.isclass(tool_or_function):\n            if issubclass(tool_or_function, Toolbox):\n                tool = tool_or_function\n                if current_plugin_name:\n                    tool.plugin = current_plugin_name\n                tool.name = name or tool.__name__\n            else:\n                raise TypeError(\n                    \"Toolbox classes must inherit from llm.Toolbox, {} does not.\".format(\n                        tool_or_function.__name__\n                    )\n                )\n\n        # If it's already a Tool instance, use it directly\n        elif isinstance(tool_or_function, Tool):\n            tool = tool_or_function\n            if name:\n                tool.name = name\n            if current_plugin_name:\n                tool.plugin = current_plugin_name\n\n        # If it's a bare function, wrap it in a Tool\n        else:\n            tool = Tool.function(tool_or_function, name=name)\n            if current_plugin_name:\n                tool.plugin = current_plugin_name\n\n        # Get the name for the tool/toolbox\n        if tool:\n            # For Toolbox classes, use their name attribute or class name\n            if inspect.isclass(tool) and issubclass(tool, Toolbox):\n                prefix = name or getattr(tool, \"name\", tool.__name__) or \"\"\n            else:\n                prefix = name or tool.name or \"\"\n\n            suffix = 0\n            candidate = prefix\n\n            # Avoid name collisions\n            while candidate in tools:\n                suffix += 1\n                candidate = f\"{prefix}_{suffix}\"\n\n            tools[candidate] = tool\n\n    # Call each plugin's register_tools hook individually to track current_plugin_name\n    for plugin in pm.get_plugins():\n        current_plugin_name = pm.get_name(plugin)\n        hook_caller = pm.hook.register_tools\n        plugin_impls = [\n            impl for impl in hook_caller.get_hookimpls() if impl.plugin is plugin\n        ]\n        for impl in plugin_impls:\n            impl.function(register=register)\n\n    return tools\n\n\ndef get_embedding_models_with_aliases() -> List[\"EmbeddingModelWithAliases\"]:\n    model_aliases = []\n\n    # Include aliases from aliases.json\n    aliases_path = user_dir() / \"aliases.json\"\n    extra_model_aliases: Dict[str, list] = {}\n    if aliases_path.exists():\n        configured_aliases = json.loads(aliases_path.read_text())\n        for alias, model_id in configured_aliases.items():\n            extra_model_aliases.setdefault(model_id, []).append(alias)\n\n    def register(model, aliases=None):\n        alias_list = list(aliases or [])\n        if model.model_id in extra_model_aliases:\n            alias_list.extend(extra_model_aliases[model.model_id])\n        model_aliases.append(EmbeddingModelWithAliases(model, alias_list))\n\n    load_plugins()\n    pm.hook.register_embedding_models(register=register)\n\n    return model_aliases\n\n\ndef get_embedding_models():\n    models = []\n\n    def register(model, aliases=None):\n        models.append(model)\n\n    load_plugins()\n    pm.hook.register_embedding_models(register=register)\n    return models\n\n\ndef get_embedding_model(name):\n    aliases = get_embedding_model_aliases()\n    try:\n        return aliases[name]\n    except KeyError:\n        raise UnknownModelError(\"Unknown model: \" + str(name))\n\n\ndef get_embedding_model_aliases() -> Dict[str, EmbeddingModel]:\n    model_aliases = {}\n    for model_with_aliases in get_embedding_models_with_aliases():\n        for alias in model_with_aliases.aliases:\n            model_aliases[alias] = model_with_aliases.model\n        model_aliases[model_with_aliases.model.model_id] = model_with_aliases.model\n    return model_aliases\n\n\ndef get_async_model_aliases() -> Dict[str, AsyncModel]:\n    async_model_aliases = {}\n    for model_with_aliases in get_models_with_aliases():\n        if model_with_aliases.async_model:\n            for alias in model_with_aliases.aliases:\n                async_model_aliases[alias] = model_with_aliases.async_model\n            async_model_aliases[model_with_aliases.model.model_id] = (\n                model_with_aliases.async_model\n            )\n    return async_model_aliases\n\n\ndef get_model_aliases() -> Dict[str, Model]:\n    model_aliases = {}\n    for model_with_aliases in get_models_with_aliases():\n        if model_with_aliases.model:\n            for alias in model_with_aliases.aliases:\n                model_aliases[alias] = model_with_aliases.model\n            model_aliases[model_with_aliases.model.model_id] = model_with_aliases.model\n    return model_aliases\n\n\nclass UnknownModelError(KeyError):\n    pass\n\n\ndef get_models() -> List[Model]:\n    \"Get all registered models\"\n    models_with_aliases = get_models_with_aliases()\n    return [mwa.model for mwa in models_with_aliases if mwa.model]\n\n\ndef get_async_models() -> List[AsyncModel]:\n    \"Get all registered async models\"\n    models_with_aliases = get_models_with_aliases()\n    return [mwa.async_model for mwa in models_with_aliases if mwa.async_model]\n\n\ndef get_async_model(name: Optional[str] = None) -> AsyncModel:\n    \"Get an async model by name or alias\"\n    aliases = get_async_model_aliases()\n    name = name or get_default_model()\n    try:\n        return aliases[name]\n    except KeyError:\n        # Does a sync model exist?\n        sync_model = None\n        try:\n            sync_model = get_model(name, _skip_async=True)\n        except UnknownModelError:\n            pass\n        if sync_model:\n            raise UnknownModelError(\"Unknown async model (sync model exists): \" + name)\n        else:\n            raise UnknownModelError(\"Unknown model: \" + name)\n\n\ndef get_model(name: Optional[str] = None, _skip_async: bool = False) -> Model:\n    \"Get a model by name or alias\"\n    aliases = get_model_aliases()\n    name = name or get_default_model()\n    try:\n        return aliases[name]\n    except KeyError:\n        # Does an async model exist?\n        if _skip_async:\n            raise UnknownModelError(\"Unknown model: \" + name)\n        async_model = None\n        try:\n            async_model = get_async_model(name)\n        except UnknownModelError:\n            pass\n        if async_model:\n            raise UnknownModelError(\"Unknown model (async model exists): \" + name)\n        else:\n            raise UnknownModelError(\"Unknown model: \" + name)\n\n\ndef get_key(\n    explicit_key: Optional[str] = None,\n    key_alias: Optional[str] = None,\n    env_var: Optional[str] = None,\n    *,\n    alias: Optional[str] = None,\n    env: Optional[str] = None,\n    input: Optional[str] = None,\n) -> Optional[str]:\n    \"\"\"\n    Return an API key based on a hierarchy of potential sources. You should use the keyword arguments,\n    the positional arguments are here purely for backwards-compatibility with older code.\n\n    :param input: Input provided by the user. This may be the key, or an alias of a key in keys.json.\n    :param alias: The alias used to retrieve the key from the keys.json file.\n    :param env: Name of the environment variable to check for the key as a final fallback.\n    \"\"\"\n    if alias:\n        key_alias = alias\n    if env:\n        env_var = env\n    if input:\n        explicit_key = input\n    stored_keys = load_keys()\n    # If user specified an alias, use the key stored for that alias\n    if explicit_key in stored_keys:\n        return stored_keys[explicit_key]\n    if explicit_key:\n        # User specified a key that's not an alias, use that\n        return explicit_key\n    # Stored key over-rides environment variables over-ride the default key\n    if key_alias in stored_keys:\n        return stored_keys[key_alias]\n    # Finally try environment variable\n    if env_var and os.environ.get(env_var):\n        return os.environ[env_var]\n    # Couldn't find it\n    return None\n\n\ndef load_keys():\n    path = user_dir() / \"keys.json\"\n    if path.exists():\n        return json.loads(path.read_text())\n    else:\n        return {}\n\n\ndef user_dir():\n    llm_user_path = os.environ.get(\"LLM_USER_PATH\")\n    if llm_user_path:\n        path = pathlib.Path(llm_user_path)\n    else:\n        path = pathlib.Path(click.get_app_dir(\"io.datasette.llm\"))\n    path.mkdir(exist_ok=True, parents=True)\n    return path\n\n\ndef set_alias(alias, model_id_or_alias):\n    \"\"\"\n    Set an alias to point to the specified model.\n    \"\"\"\n    path = user_dir() / \"aliases.json\"\n    path.parent.mkdir(parents=True, exist_ok=True)\n    if not path.exists():\n        path.write_text(\"{}\\n\")\n    try:\n        current = json.loads(path.read_text())\n    except json.decoder.JSONDecodeError:\n        # We're going to write a valid JSON file in a moment:\n        current = {}\n    # Resolve model_id_or_alias to a model_id\n    try:\n        model = get_model(model_id_or_alias)\n        model_id = model.model_id\n    except UnknownModelError:\n        # Try to resolve it to an embedding model\n        try:\n            model = get_embedding_model(model_id_or_alias)\n            model_id = model.model_id\n        except UnknownModelError:\n            # Set the alias to the exact string they provided instead\n            model_id = model_id_or_alias\n    current[alias] = model_id\n    path.write_text(json.dumps(current, indent=4) + \"\\n\")\n\n\ndef remove_alias(alias):\n    \"\"\"\n    Remove an alias.\n    \"\"\"\n    path = user_dir() / \"aliases.json\"\n    if not path.exists():\n        raise KeyError(\"No aliases.json file exists\")\n    try:\n        current = json.loads(path.read_text())\n    except json.decoder.JSONDecodeError:\n        raise KeyError(\"aliases.json file is not valid JSON\")\n    if alias not in current:\n        raise KeyError(\"No such alias: {}\".format(alias))\n    del current[alias]\n    path.write_text(json.dumps(current, indent=4) + \"\\n\")\n\n\ndef encode(values):\n    return struct.pack(\"<\" + \"f\" * len(values), *values)\n\n\ndef decode(binary):\n    return struct.unpack(\"<\" + \"f\" * (len(binary) // 4), binary)\n\n\ndef cosine_similarity(a, b):\n    dot_product = sum(x * y for x, y in zip(a, b))\n    magnitude_a = sum(x * x for x in a) ** 0.5\n    magnitude_b = sum(x * x for x in b) ** 0.5\n    return dot_product / (magnitude_a * magnitude_b)\n\n\ndef get_default_model(filename=\"default_model.txt\", default=DEFAULT_MODEL):\n    path = user_dir() / filename\n    if path.exists():\n        return path.read_text().strip()\n    else:\n        return default\n\n\ndef set_default_model(model, filename=\"default_model.txt\"):\n    path = user_dir() / filename\n    if model is None and path.exists():\n        path.unlink()\n    else:\n        path.write_text(model)\n\n\ndef get_default_embedding_model():\n    return get_default_model(\"default_embedding_model.txt\", None)\n\n\ndef set_default_embedding_model(model):\n    set_default_model(model, \"default_embedding_model.txt\")\n"
  },
  {
    "path": "llm/__main__.py",
    "content": "from .cli import cli\n\nif __name__ == \"__main__\":\n    cli()\n"
  },
  {
    "path": "llm/cli.py",
    "content": "import asyncio\nimport click\nfrom click_default_group import DefaultGroup\nfrom dataclasses import asdict\nfrom importlib.metadata import version\nimport io\nimport json\nimport os\nfrom llm import (\n    Attachment,\n    AsyncConversation,\n    AsyncKeyModel,\n    AsyncResponse,\n    CancelToolCall,\n    Collection,\n    Conversation,\n    Fragment,\n    Response,\n    Template,\n    Tool,\n    Toolbox,\n    UnknownModelError,\n    KeyModel,\n    encode,\n    get_async_model,\n    get_default_model,\n    get_default_embedding_model,\n    get_embedding_models_with_aliases,\n    get_embedding_model_aliases,\n    get_embedding_model,\n    get_plugins,\n    get_tools,\n    get_fragment_loaders,\n    get_template_loaders,\n    get_model,\n    get_model_aliases,\n    get_models_with_aliases,\n    user_dir,\n    set_alias,\n    set_default_model,\n    set_default_embedding_model,\n    remove_alias,\n)\nfrom llm.models import _BaseConversation, ChainResponse\n\nfrom .migrations import migrate\nfrom .plugins import pm, load_plugins\nfrom .utils import (\n    ensure_fragment,\n    extract_fenced_code_block,\n    find_unused_key,\n    has_plugin_prefix,\n    instantiate_from_spec,\n    make_schema_id,\n    maybe_fenced_code,\n    mimetype_from_path,\n    mimetype_from_string,\n    multi_schema,\n    output_rows_as_json,\n    resolve_schema_input,\n    schema_dsl,\n    schema_summary,\n    token_usage_string,\n    truncate_string,\n)\nimport base64\nimport httpx\nimport inspect\nimport pathlib\nimport pydantic\nimport re\nimport readline\nfrom runpy import run_module\nimport shutil\nimport sqlite_utils\nfrom sqlite_utils.utils import rows_from_file, Format\nimport sys\nimport textwrap\nfrom typing import cast, Dict, Optional, Iterable, List, Union, Tuple, Type, Any\nimport warnings\nimport yaml\n\nwarnings.simplefilter(\"ignore\", ResourceWarning)\n\nDEFAULT_TEMPLATE = \"prompt: \"\n\n\nclass FragmentNotFound(Exception):\n    pass\n\n\ndef validate_fragment_alias(ctx, param, value):\n    if not re.match(r\"^[a-zA-Z0-9_-]+$\", value):\n        raise click.BadParameter(\"Fragment alias must be alphanumeric\")\n    return value\n\n\ndef resolve_fragments(\n    db: sqlite_utils.Database, fragments: Iterable[str], allow_attachments: bool = False\n) -> List[Union[Fragment, Attachment]]:\n    \"\"\"\n    Resolve fragment strings into a mixed of llm.Fragment() and llm.Attachment() objects.\n    \"\"\"\n\n    def _load_by_alias(fragment: str) -> Tuple[Optional[str], Optional[str]]:\n        rows = list(\n            db.query(\n                \"\"\"\n                select content, source from fragments\n                left join fragment_aliases on fragments.id = fragment_aliases.fragment_id\n                where alias = :alias or hash = :alias limit 1\n                \"\"\",\n                {\"alias\": fragment},\n            )\n        )\n        if rows:\n            row = rows[0]\n            return row[\"content\"], row[\"source\"]\n        return None, None\n\n    # The fragment strings could be URLs or paths or plugin references\n    resolved: List[Union[Fragment, Attachment]] = []\n    for fragment in fragments:\n        if fragment.startswith(\"http://\") or fragment.startswith(\"https://\"):\n            llm_version = version(\"llm\")\n            headers = {\"User-Agent\": f\"llm/{llm_version} (https://llm.datasette.io/)\"}\n            client = httpx.Client(\n                follow_redirects=True, max_redirects=3, headers=headers\n            )\n            response = client.get(fragment)\n            response.raise_for_status()\n            resolved.append(Fragment(response.text, fragment))\n        elif fragment == \"-\":\n            resolved.append(Fragment(sys.stdin.read(), \"-\"))\n        elif has_plugin_prefix(fragment):\n            prefix, rest = fragment.split(\":\", 1)\n            loaders = get_fragment_loaders()\n            if prefix not in loaders:\n                raise FragmentNotFound(\"Unknown fragment prefix: {}\".format(prefix))\n            loader = loaders[prefix]\n            try:\n                result = loader(rest)\n                if not isinstance(result, list):\n                    result = [result]\n                if not allow_attachments and any(\n                    isinstance(r, Attachment) for r in result\n                ):\n                    raise FragmentNotFound(\n                        \"Fragment loader {} returned a disallowed attachment\".format(\n                            prefix\n                        )\n                    )\n                resolved.extend(result)\n            except Exception as ex:\n                raise FragmentNotFound(\n                    \"Could not load fragment {}: {}\".format(fragment, ex)\n                )\n        else:\n            # Try from the DB\n            content, source = _load_by_alias(fragment)\n            if content is not None:\n                resolved.append(Fragment(content, source))\n            else:\n                # Now try path\n                path = pathlib.Path(fragment)\n                if path.exists():\n                    resolved.append(Fragment(path.read_text(), str(path.resolve())))\n                else:\n                    raise FragmentNotFound(f\"Fragment '{fragment}' not found\")\n    return resolved\n\n\ndef process_fragments_in_chat(\n    db: sqlite_utils.Database, prompt: str\n) -> tuple[str, list[Fragment], list[Attachment]]:\n    \"\"\"\n    Process any !fragment commands in a chat prompt and return the modified prompt plus resolved fragments and attachments.\n    \"\"\"\n    prompt_lines = []\n    fragments = []\n    attachments = []\n    for line in prompt.splitlines():\n        if line.startswith(\"!fragment \"):\n            try:\n                fragment_strs = line.strip().removeprefix(\"!fragment \").split()\n                fragments_and_attachments = resolve_fragments(\n                    db, fragments=fragment_strs, allow_attachments=True\n                )\n                fragments += [\n                    fragment\n                    for fragment in fragments_and_attachments\n                    if isinstance(fragment, Fragment)\n                ]\n                attachments += [\n                    attachment\n                    for attachment in fragments_and_attachments\n                    if isinstance(attachment, Attachment)\n                ]\n            except FragmentNotFound as ex:\n                raise click.ClickException(str(ex))\n        else:\n            prompt_lines.append(line)\n    return \"\\n\".join(prompt_lines), fragments, attachments\n\n\nclass AttachmentError(Exception):\n    \"\"\"Exception raised for errors in attachment resolution.\"\"\"\n\n    pass\n\n\ndef resolve_attachment(value):\n    \"\"\"\n    Resolve an attachment from a string value which could be:\n    - \"-\" for stdin\n    - A URL\n    - A file path\n\n    Returns an Attachment object.\n    Raises AttachmentError if the attachment cannot be resolved.\n    \"\"\"\n    if value == \"-\":\n        content = sys.stdin.buffer.read()\n        # Try to guess type\n        mimetype = mimetype_from_string(content)\n        if mimetype is None:\n            raise AttachmentError(\"Could not determine mimetype of stdin\")\n        return Attachment(type=mimetype, path=None, url=None, content=content)\n\n    if \"://\" in value:\n        # Confirm URL exists and try to guess type\n        try:\n            response = httpx.head(value)\n            response.raise_for_status()\n            mimetype = response.headers.get(\"content-type\")\n        except httpx.HTTPError as ex:\n            raise AttachmentError(str(ex))\n        return Attachment(type=mimetype, path=None, url=value, content=None)\n\n    # Check that the file exists\n    path = pathlib.Path(value)\n    if not path.exists():\n        raise AttachmentError(f\"File {value} does not exist\")\n    path = path.resolve()\n\n    # Try to guess type\n    mimetype = mimetype_from_path(str(path))\n    if mimetype is None:\n        raise AttachmentError(f\"Could not determine mimetype of {value}\")\n\n    return Attachment(type=mimetype, path=str(path), url=None, content=None)\n\n\nclass AttachmentType(click.ParamType):\n    name = \"attachment\"\n\n    def convert(self, value, param, ctx):\n        try:\n            return resolve_attachment(value)\n        except AttachmentError as e:\n            self.fail(str(e), param, ctx)\n\n\ndef resolve_attachment_with_type(value: str, mimetype: str) -> Attachment:\n    if \"://\" in value:\n        attachment = Attachment(mimetype, None, value, None)\n    elif value == \"-\":\n        content = sys.stdin.buffer.read()\n        attachment = Attachment(mimetype, None, None, content)\n    else:\n        # Look for file\n        path = pathlib.Path(value)\n        if not path.exists():\n            raise click.BadParameter(f\"File {value} does not exist\")\n        path = path.resolve()\n        attachment = Attachment(mimetype, str(path), None, None)\n    return attachment\n\n\ndef attachment_types_callback(ctx, param, values) -> List[Attachment]:\n    collected = []\n    for value, mimetype in values:\n        collected.append(resolve_attachment_with_type(value, mimetype))\n    return collected\n\n\ndef json_validator(object_name):\n    def validator(ctx, param, value):\n        if value is None:\n            return value\n        try:\n            obj = json.loads(value)\n            if not isinstance(obj, dict):\n                raise click.BadParameter(f\"{object_name} must be a JSON object\")\n            return obj\n        except json.JSONDecodeError:\n            raise click.BadParameter(f\"{object_name} must be valid JSON\")\n\n    return validator\n\n\ndef schema_option(fn):\n    click.option(\n        \"schema_input\",\n        \"--schema\",\n        help=\"JSON schema, filepath or ID\",\n    )(fn)\n    return fn\n\n\n@click.group(\n    cls=DefaultGroup,\n    default=\"prompt\",\n    default_if_no_args=True,\n    context_settings={\"help_option_names\": [\"-h\", \"--help\"]},\n)\n@click.version_option()\ndef cli():\n    \"\"\"\n    Access Large Language Models from the command-line\n\n    Documentation: https://llm.datasette.io/\n\n    LLM can run models from many different providers. Consult the\n    plugin directory for a list of available models:\n\n    https://llm.datasette.io/en/stable/plugins/directory.html\n\n    To get started with OpenAI, obtain an API key from them and:\n\n    \\b\n        $ llm keys set openai\n        Enter key: ...\n\n    Then execute a prompt like this:\n\n        llm 'Five outrageous names for a pet pelican'\n\n    For a full list of prompting options run:\n\n        llm prompt --help\n    \"\"\"\n\n\n@cli.command(name=\"prompt\")\n@click.argument(\"prompt\", required=False)\n@click.option(\"-s\", \"--system\", help=\"System prompt to use\")\n@click.option(\"model_id\", \"-m\", \"--model\", help=\"Model to use\", envvar=\"LLM_MODEL\")\n@click.option(\n    \"-d\",\n    \"--database\",\n    type=click.Path(readable=True, dir_okay=False),\n    help=\"Path to log database\",\n)\n@click.option(\n    \"queries\",\n    \"-q\",\n    \"--query\",\n    multiple=True,\n    help=\"Use first model matching these strings\",\n)\n@click.option(\n    \"attachments\",\n    \"-a\",\n    \"--attachment\",\n    type=AttachmentType(),\n    multiple=True,\n    help=\"Attachment path or URL or -\",\n)\n@click.option(\n    \"attachment_types\",\n    \"--at\",\n    \"--attachment-type\",\n    type=(str, str),\n    multiple=True,\n    callback=attachment_types_callback,\n    help=\"\\b\\nAttachment with explicit mimetype,\\n--at image.jpg image/jpeg\",\n)\n@click.option(\n    \"tools\",\n    \"-T\",\n    \"--tool\",\n    multiple=True,\n    help=\"Name of a tool to make available to the model\",\n)\n@click.option(\n    \"python_tools\",\n    \"--functions\",\n    help=\"Python code block or file path defining functions to register as tools\",\n    multiple=True,\n)\n@click.option(\n    \"tools_debug\",\n    \"--td\",\n    \"--tools-debug\",\n    is_flag=True,\n    help=\"Show full details of tool executions\",\n    envvar=\"LLM_TOOLS_DEBUG\",\n)\n@click.option(\n    \"tools_approve\",\n    \"--ta\",\n    \"--tools-approve\",\n    is_flag=True,\n    help=\"Manually approve every tool execution\",\n)\n@click.option(\n    \"chain_limit\",\n    \"--cl\",\n    \"--chain-limit\",\n    type=int,\n    default=5,\n    help=\"How many chained tool responses to allow, default 5, set 0 for unlimited\",\n)\n@click.option(\n    \"options\",\n    \"-o\",\n    \"--option\",\n    type=(str, str),\n    multiple=True,\n    help=\"key/value options for the model\",\n)\n@schema_option\n@click.option(\n    \"--schema-multi\",\n    help=\"JSON schema to use for multiple results\",\n)\n@click.option(\n    \"fragments\",\n    \"-f\",\n    \"--fragment\",\n    multiple=True,\n    help=\"Fragment (alias, URL, hash or file path) to add to the prompt\",\n)\n@click.option(\n    \"system_fragments\",\n    \"--sf\",\n    \"--system-fragment\",\n    multiple=True,\n    help=\"Fragment to add to system prompt\",\n)\n@click.option(\"-t\", \"--template\", help=\"Template to use\")\n@click.option(\n    \"-p\",\n    \"--param\",\n    multiple=True,\n    type=(str, str),\n    help=\"Parameters for template\",\n)\n@click.option(\"--no-stream\", is_flag=True, help=\"Do not stream output\")\n@click.option(\"-n\", \"--no-log\", is_flag=True, help=\"Don't log to database\")\n@click.option(\"--log\", is_flag=True, help=\"Log prompt and response to the database\")\n@click.option(\n    \"_continue\",\n    \"-c\",\n    \"--continue\",\n    is_flag=True,\n    flag_value=-1,\n    help=\"Continue the most recent conversation.\",\n)\n@click.option(\n    \"conversation_id\",\n    \"--cid\",\n    \"--conversation\",\n    help=\"Continue the conversation with the given ID.\",\n)\n@click.option(\"--key\", help=\"API key to use\")\n@click.option(\"--save\", help=\"Save prompt with this template name\")\n@click.option(\"async_\", \"--async\", is_flag=True, help=\"Run prompt asynchronously\")\n@click.option(\"-u\", \"--usage\", is_flag=True, help=\"Show token usage\")\n@click.option(\"-x\", \"--extract\", is_flag=True, help=\"Extract first fenced code block\")\n@click.option(\n    \"extract_last\",\n    \"--xl\",\n    \"--extract-last\",\n    is_flag=True,\n    help=\"Extract last fenced code block\",\n)\ndef prompt(\n    prompt,\n    system,\n    model_id,\n    database,\n    queries,\n    attachments,\n    attachment_types,\n    tools,\n    python_tools,\n    tools_debug,\n    tools_approve,\n    chain_limit,\n    options,\n    schema_input,\n    schema_multi,\n    fragments,\n    system_fragments,\n    template,\n    param,\n    no_stream,\n    no_log,\n    log,\n    _continue,\n    conversation_id,\n    key,\n    save,\n    async_,\n    usage,\n    extract,\n    extract_last,\n):\n    \"\"\"\n    Execute a prompt\n\n    Documentation: https://llm.datasette.io/en/stable/usage.html\n\n    Examples:\n\n    \\b\n        llm 'Capital of France?'\n        llm 'Capital of France?' -m gpt-4o\n        llm 'Capital of France?' -s 'answer in Spanish'\n\n    Multi-modal models can be called with attachments like this:\n\n    \\b\n        llm 'Extract text from this image' -a image.jpg\n        llm 'Describe' -a https://static.simonwillison.net/static/2024/pelicans.jpg\n        cat image | llm 'describe image' -a -\n        # With an explicit mimetype:\n        cat image | llm 'describe image' --at - image/jpeg\n\n    The -x/--extract option returns just the content of the first ``` fenced code\n    block, if one is present. If none are present it returns the full response.\n\n    \\b\n        llm 'JavaScript function for reversing a string' -x\n    \"\"\"\n    if log and no_log:\n        raise click.ClickException(\"--log and --no-log are mutually exclusive\")\n\n    log_path = pathlib.Path(database) if database else logs_db_path()\n    (log_path.parent).mkdir(parents=True, exist_ok=True)\n    db = sqlite_utils.Database(log_path)\n    migrate(db)\n\n    if queries and not model_id:\n        # Use -q options to find model with shortest model_id\n        matches = []\n        for model_with_aliases in get_models_with_aliases():\n            if all(model_with_aliases.matches(q) for q in queries):\n                matches.append(model_with_aliases.model.model_id)\n        if not matches:\n            raise click.ClickException(\n                \"No model found matching queries {}\".format(\", \".join(queries))\n            )\n        model_id = min(matches, key=len)\n\n    if schema_multi:\n        schema_input = schema_multi\n\n    schema = resolve_schema_input(db, schema_input, load_template)\n\n    if schema_multi:\n        # Convert that schema into multiple \"items\" of the same schema\n        schema = multi_schema(schema)\n\n    def read_prompt():\n        nonlocal prompt, schema\n\n        # Is there extra prompt available on stdin?\n        stdin_prompt = None\n        if not sys.stdin.isatty():\n            stdin_prompt = sys.stdin.read()\n\n        if stdin_prompt:\n            bits = [stdin_prompt]\n            if prompt:\n                bits.append(prompt)\n            prompt = \" \".join(bits)\n\n        if (\n            prompt is None\n            and not save\n            and sys.stdin.isatty()\n            and not attachments\n            and not attachment_types\n            and not schema\n            and not fragments\n        ):\n            # Hang waiting for input to stdin (unless --save)\n            prompt = sys.stdin.read()\n        return prompt\n\n    if save:\n        # We are saving their prompt/system/etc to a new template\n        # Fields to save: prompt, system, model - and more in the future\n        disallowed_options = []\n        for option, var in (\n            (\"--template\", template),\n            (\"--continue\", _continue),\n            (\"--cid\", conversation_id),\n        ):\n            if var:\n                disallowed_options.append(option)\n        if disallowed_options:\n            raise click.ClickException(\n                \"--save cannot be used with {}\".format(\", \".join(disallowed_options))\n            )\n        path = template_dir() / f\"{save}.yaml\"\n        to_save = {}\n        if model_id:\n            model_aliases = get_model_aliases()\n            try:\n                to_save[\"model\"] = model_aliases[model_id].model_id\n            except KeyError:\n                raise click.ClickException(\"'{}' is not a known model\".format(model_id))\n        prompt = read_prompt()\n        if prompt:\n            to_save[\"prompt\"] = prompt\n        if system:\n            to_save[\"system\"] = system\n        if param:\n            to_save[\"defaults\"] = dict(param)\n        if extract:\n            to_save[\"extract\"] = True\n        if extract_last:\n            to_save[\"extract_last\"] = True\n        if schema:\n            to_save[\"schema_object\"] = schema\n        if fragments:\n            to_save[\"fragments\"] = list(fragments)\n        if system_fragments:\n            to_save[\"system_fragments\"] = list(system_fragments)\n        if python_tools:\n            to_save[\"functions\"] = \"\\n\\n\".join(python_tools)\n        if tools:\n            to_save[\"tools\"] = list(tools)\n        if attachments:\n            # Only works for attachments with a path or url\n            to_save[\"attachments\"] = [\n                (a.path or a.url) for a in attachments if (a.path or a.url)\n            ]\n        if attachment_types:\n            to_save[\"attachment_types\"] = [\n                {\"type\": a.type, \"value\": a.path or a.url}\n                for a in attachment_types\n                if (a.path or a.url)\n            ]\n        if options:\n            # Need to validate and convert their types first\n            model = get_model(model_id or get_default_model())\n            try:\n                options_model = model.Options(**dict(options))\n                # Use model_dump(mode=\"json\") so Enums become their .value strings\n                to_save[\"options\"] = {\n                    k: v\n                    for k, v in options_model.model_dump(mode=\"json\").items()\n                    if v is not None\n                }\n            except pydantic.ValidationError as ex:\n                raise click.ClickException(render_errors(ex.errors()))\n        path.write_text(\n            yaml.safe_dump(\n                to_save,\n                indent=4,\n                default_flow_style=False,\n                sort_keys=False,\n            ),\n            \"utf-8\",\n        )\n        return\n\n    if template:\n        params = dict(param)\n        # Cannot be used with system\n        try:\n            template_obj = load_template(template)\n        except LoadTemplateError as ex:\n            raise click.ClickException(str(ex))\n        if not (extract or extract_last):\n            extract = template_obj.extract\n            extract_last = template_obj.extract_last\n        # Combine with template fragments/system_fragments\n        if template_obj.fragments:\n            fragments = [*template_obj.fragments, *fragments]\n        if template_obj.system_fragments:\n            system_fragments = [*template_obj.system_fragments, *system_fragments]\n        if template_obj.schema_object:\n            schema = template_obj.schema_object\n        if template_obj.tools:\n            tools = [*template_obj.tools, *tools]\n        if template_obj.functions and template_obj._functions_is_trusted:\n            python_tools = [template_obj.functions, *python_tools]\n        input_ = \"\"\n        if template_obj.options:\n            # Make options mutable (they start as a tuple)\n            options = list(options)\n            # Load any options, provided they were not set using -o already\n            specified_options = dict(options)\n            for option_name, option_value in template_obj.options.items():\n                if option_name not in specified_options:\n                    options.append((option_name, option_value))\n        if \"input\" in template_obj.vars():\n            input_ = read_prompt()\n        try:\n            template_prompt, template_system = template_obj.evaluate(input_, params)\n            if template_prompt:\n                # Combine with user prompt\n                if prompt and \"input\" not in template_obj.vars():\n                    prompt = template_prompt + \"\\n\" + prompt\n                else:\n                    prompt = template_prompt\n            if template_system and not system:\n                system = template_system\n        except Template.MissingVariables as ex:\n            raise click.ClickException(str(ex))\n        if model_id is None and template_obj.model:\n            model_id = template_obj.model\n        # Merge in any attachments\n        if template_obj.attachments:\n            attachments = [\n                resolve_attachment(a) for a in template_obj.attachments\n            ] + list(attachments)\n        if template_obj.attachment_types:\n            attachment_types = [\n                resolve_attachment_with_type(at.value, at.type)\n                for at in template_obj.attachment_types\n            ] + list(attachment_types)\n    if extract or extract_last:\n        no_stream = True\n\n    conversation = None\n    if conversation_id or _continue:\n        # Load the conversation - loads most recent if no ID provided\n        try:\n            conversation = load_conversation(\n                conversation_id, async_=async_, database=database\n            )\n        except UnknownModelError as ex:\n            raise click.ClickException(str(ex))\n\n    if conversation_tools := _get_conversation_tools(conversation, tools):\n        tools = conversation_tools\n\n    # Figure out which model we are using\n    if model_id is None:\n        if conversation:\n            model_id = conversation.model.model_id\n        else:\n            model_id = get_default_model()\n\n    # Now resolve the model\n    try:\n        if async_:\n            model = get_async_model(model_id)\n        else:\n            model = get_model(model_id)\n    except UnknownModelError as ex:\n        raise click.ClickException(ex)\n\n    if conversation is None and (tools or python_tools):\n        conversation = model.conversation()\n\n    if conversation:\n        # To ensure it can see the key\n        conversation.model = model\n\n    # Validate options\n    validated_options = {}\n    if options:\n        # Validate with pydantic\n        try:\n            validated_options = dict(\n                (key, value)\n                for key, value in model.Options(**dict(options))\n                if value is not None\n            )\n        except pydantic.ValidationError as ex:\n            raise click.ClickException(render_errors(ex.errors()))\n\n    # Add on any default model options\n    default_options = get_model_options(model.model_id)\n    for key_, value in default_options.items():\n        if key_ not in validated_options:\n            validated_options[key_] = value\n\n    kwargs = {}\n\n    resolved_attachments = [*attachments, *attachment_types]\n\n    should_stream = model.can_stream and not no_stream\n    if not should_stream:\n        kwargs[\"stream\"] = False\n\n    if isinstance(model, (KeyModel, AsyncKeyModel)):\n        kwargs[\"key\"] = key\n\n    prompt = read_prompt()\n    response = None\n\n    try:\n        fragments_and_attachments = resolve_fragments(\n            db, fragments, allow_attachments=True\n        )\n        resolved_fragments = [\n            fragment\n            for fragment in fragments_and_attachments\n            if isinstance(fragment, Fragment)\n        ]\n        resolved_attachments.extend(\n            attachment\n            for attachment in fragments_and_attachments\n            if isinstance(attachment, Attachment)\n        )\n        resolved_system_fragments = resolve_fragments(db, system_fragments)\n    except FragmentNotFound as ex:\n        raise click.ClickException(str(ex))\n\n    prompt_method = model.prompt\n    if conversation:\n        prompt_method = conversation.prompt\n\n    tool_implementations = _gather_tools(tools, python_tools)\n\n    if tool_implementations:\n        prompt_method = conversation.chain\n        kwargs[\"options\"] = validated_options\n        kwargs[\"chain_limit\"] = chain_limit\n        if tools_debug:\n            kwargs[\"after_call\"] = _debug_tool_call\n        if tools_approve:\n            kwargs[\"before_call\"] = _approve_tool_call\n        kwargs[\"tools\"] = tool_implementations\n    else:\n        # Merge in options for the .prompt() methods\n        kwargs.update(validated_options)\n\n    try:\n        if async_:\n\n            async def inner():\n                if should_stream:\n                    response = prompt_method(\n                        prompt,\n                        attachments=resolved_attachments,\n                        system=system,\n                        schema=schema,\n                        fragments=resolved_fragments,\n                        system_fragments=resolved_system_fragments,\n                        **kwargs,\n                    )\n                    async for chunk in response:\n                        print(chunk, end=\"\")\n                        sys.stdout.flush()\n                    print(\"\")\n                else:\n                    response = prompt_method(\n                        prompt,\n                        fragments=resolved_fragments,\n                        attachments=resolved_attachments,\n                        schema=schema,\n                        system=system,\n                        system_fragments=resolved_system_fragments,\n                        **kwargs,\n                    )\n                    text = await response.text()\n                    if extract or extract_last:\n                        text = (\n                            extract_fenced_code_block(text, last=extract_last) or text\n                        )\n                    print(text)\n                return response\n\n            response = asyncio.run(inner())\n        else:\n            response = prompt_method(\n                prompt,\n                fragments=resolved_fragments,\n                attachments=resolved_attachments,\n                system=system,\n                schema=schema,\n                system_fragments=resolved_system_fragments,\n                **kwargs,\n            )\n            if should_stream:\n                for chunk in response:\n                    print(chunk, end=\"\")\n                    sys.stdout.flush()\n                print(\"\")\n            else:\n                text = response.text()\n                if extract or extract_last:\n                    text = extract_fenced_code_block(text, last=extract_last) or text\n                print(text)\n    # List of exceptions that should never be raised in pytest:\n    except (ValueError, NotImplementedError) as ex:\n        raise click.ClickException(str(ex))\n    except Exception as ex:\n        # All other exceptions should raise in pytest, show to user otherwise\n        if getattr(sys, \"_called_from_test\", False) or os.environ.get(\n            \"LLM_RAISE_ERRORS\", None\n        ):\n            raise\n        raise click.ClickException(str(ex))\n\n    if usage:\n        if isinstance(response, ChainResponse):\n            responses = response._responses\n        else:\n            responses = [response]\n        for response_object in responses:\n            # Show token usage to stderr in yellow\n            click.echo(\n                click.style(\n                    \"Token usage: {}\".format(response_object.token_usage()),\n                    fg=\"yellow\",\n                    bold=True,\n                ),\n                err=True,\n            )\n\n    # Log responses to the database\n    if (logs_on() or log) and not no_log:\n        # Could be Response, AsyncResponse, ChainResponse, AsyncChainResponse\n        if isinstance(response, AsyncResponse):\n            response = asyncio.run(response.to_sync_response())\n        # At this point ALL forms should have a log_to_db() method that works:\n        response.log_to_db(db)\n\n\n@cli.command()\n@click.option(\"-s\", \"--system\", help=\"System prompt to use\")\n@click.option(\"model_id\", \"-m\", \"--model\", help=\"Model to use\", envvar=\"LLM_MODEL\")\n@click.option(\n    \"_continue\",\n    \"-c\",\n    \"--continue\",\n    is_flag=True,\n    flag_value=-1,\n    help=\"Continue the most recent conversation.\",\n)\n@click.option(\n    \"conversation_id\",\n    \"--cid\",\n    \"--conversation\",\n    help=\"Continue the conversation with the given ID.\",\n)\n@click.option(\n    \"fragments\",\n    \"-f\",\n    \"--fragment\",\n    multiple=True,\n    help=\"Fragment (alias, URL, hash or file path) to add to the prompt\",\n)\n@click.option(\n    \"system_fragments\",\n    \"--sf\",\n    \"--system-fragment\",\n    multiple=True,\n    help=\"Fragment to add to system prompt\",\n)\n@click.option(\"-t\", \"--template\", help=\"Template to use\")\n@click.option(\n    \"-p\",\n    \"--param\",\n    multiple=True,\n    type=(str, str),\n    help=\"Parameters for template\",\n)\n@click.option(\n    \"options\",\n    \"-o\",\n    \"--option\",\n    type=(str, str),\n    multiple=True,\n    help=\"key/value options for the model\",\n)\n@click.option(\n    \"-d\",\n    \"--database\",\n    type=click.Path(readable=True, dir_okay=False),\n    help=\"Path to log database\",\n)\n@click.option(\"--no-stream\", is_flag=True, help=\"Do not stream output\")\n@click.option(\"--key\", help=\"API key to use\")\n@click.option(\n    \"tools\",\n    \"-T\",\n    \"--tool\",\n    multiple=True,\n    help=\"Name of a tool to make available to the model\",\n)\n@click.option(\n    \"python_tools\",\n    \"--functions\",\n    help=\"Python code block or file path defining functions to register as tools\",\n    multiple=True,\n)\n@click.option(\n    \"tools_debug\",\n    \"--td\",\n    \"--tools-debug\",\n    is_flag=True,\n    help=\"Show full details of tool executions\",\n    envvar=\"LLM_TOOLS_DEBUG\",\n)\n@click.option(\n    \"tools_approve\",\n    \"--ta\",\n    \"--tools-approve\",\n    is_flag=True,\n    help=\"Manually approve every tool execution\",\n)\n@click.option(\n    \"chain_limit\",\n    \"--cl\",\n    \"--chain-limit\",\n    type=int,\n    default=5,\n    help=\"How many chained tool responses to allow, default 5, set 0 for unlimited\",\n)\ndef chat(\n    system,\n    model_id,\n    _continue,\n    conversation_id,\n    fragments,\n    system_fragments,\n    template,\n    param,\n    options,\n    no_stream,\n    key,\n    database,\n    tools,\n    python_tools,\n    tools_debug,\n    tools_approve,\n    chain_limit,\n):\n    \"\"\"\n    Hold an ongoing chat with a model.\n    \"\"\"\n    # Left and right arrow keys to move cursor:\n    if sys.platform != \"win32\":\n        readline.parse_and_bind(\"\\\\e[D: backward-char\")\n        readline.parse_and_bind(\"\\\\e[C: forward-char\")\n    else:\n        readline.parse_and_bind(\"bind -x '\\\\e[D: backward-char'\")\n        readline.parse_and_bind(\"bind -x '\\\\e[C: forward-char'\")\n    log_path = pathlib.Path(database) if database else logs_db_path()\n    (log_path.parent).mkdir(parents=True, exist_ok=True)\n    db = sqlite_utils.Database(log_path)\n    migrate(db)\n\n    conversation = None\n    if conversation_id or _continue:\n        # Load the conversation - loads most recent if no ID provided\n        try:\n            conversation = load_conversation(conversation_id, database=database)\n        except UnknownModelError as ex:\n            raise click.ClickException(str(ex))\n\n    if conversation_tools := _get_conversation_tools(conversation, tools):\n        tools = conversation_tools\n\n    template_obj = None\n    if template:\n        params = dict(param)\n        try:\n            template_obj = load_template(template)\n        except LoadTemplateError as ex:\n            raise click.ClickException(str(ex))\n        if model_id is None and template_obj.model:\n            model_id = template_obj.model\n        if template_obj.tools:\n            tools = [*template_obj.tools, *tools]\n        if template_obj.functions and template_obj._functions_is_trusted:\n            python_tools = [template_obj.functions, *python_tools]\n\n    # Figure out which model we are using\n    if model_id is None:\n        if conversation:\n            model_id = conversation.model.model_id\n        else:\n            model_id = get_default_model()\n\n    # Now resolve the model\n    try:\n        model = get_model(model_id)\n    except KeyError:\n        raise click.ClickException(\"'{}' is not a known model\".format(model_id))\n\n    if conversation is None:\n        # Start a fresh conversation for this chat\n        conversation = Conversation(model=model)\n    else:\n        # Ensure it can see the API key\n        conversation.model = model\n\n    if tools_debug:\n        conversation.after_call = _debug_tool_call\n    if tools_approve:\n        conversation.before_call = _approve_tool_call\n\n    # Validate options\n    validated_options = get_model_options(model.model_id)\n    if options:\n        try:\n            validated_options = dict(\n                (key, value)\n                for key, value in model.Options(**dict(options))\n                if value is not None\n            )\n        except pydantic.ValidationError as ex:\n            raise click.ClickException(render_errors(ex.errors()))\n\n    kwargs = {}\n    if validated_options:\n        kwargs[\"options\"] = validated_options\n\n    tool_functions = _gather_tools(tools, python_tools)\n\n    if tool_functions:\n        kwargs[\"chain_limit\"] = chain_limit\n        kwargs[\"tools\"] = tool_functions\n\n    should_stream = model.can_stream and not no_stream\n    if not should_stream:\n        kwargs[\"stream\"] = False\n\n    if key and isinstance(model, KeyModel):\n        kwargs[\"key\"] = key\n\n    try:\n        fragments_and_attachments = resolve_fragments(\n            db, fragments, allow_attachments=True\n        )\n        argument_fragments = [\n            fragment\n            for fragment in fragments_and_attachments\n            if isinstance(fragment, Fragment)\n        ]\n        argument_attachments = [\n            attachment\n            for attachment in fragments_and_attachments\n            if isinstance(attachment, Attachment)\n        ]\n        argument_system_fragments = resolve_fragments(db, system_fragments)\n    except FragmentNotFound as ex:\n        raise click.ClickException(str(ex))\n\n    click.echo(\"Chatting with {}\".format(model.model_id))\n    click.echo(\"Type 'exit' or 'quit' to exit\")\n    click.echo(\"Type '!multi' to enter multiple lines, then '!end' to finish\")\n    click.echo(\"Type '!edit' to open your default editor and modify the prompt\")\n    click.echo(\n        \"Type '!fragment <my_fragment> [<another_fragment> ...]' to insert one or more fragments\"\n    )\n    in_multi = False\n\n    accumulated = []\n    accumulated_fragments = []\n    accumulated_attachments = []\n    end_token = \"!end\"\n    while True:\n        prompt = click.prompt(\"\", prompt_suffix=\"> \" if not in_multi else \"\")\n        fragments = []\n        attachments = []\n        if argument_fragments:\n            fragments += argument_fragments\n            # fragments from --fragments will get added to the first message only\n            argument_fragments = []\n        if argument_attachments:\n            attachments = argument_attachments\n            argument_attachments = []\n        if prompt.strip().startswith(\"!multi\"):\n            in_multi = True\n            bits = prompt.strip().split()\n            if len(bits) > 1:\n                end_token = \"!end {}\".format(\" \".join(bits[1:]))\n            continue\n        if prompt.strip() == \"!edit\":\n            edited_prompt = click.edit()\n            if edited_prompt is None:\n                click.echo(\"Editor closed without saving.\", err=True)\n                continue\n            prompt = edited_prompt.strip()\n        if prompt.strip().startswith(\"!fragment \"):\n            prompt, fragments, attachments = process_fragments_in_chat(db, prompt)\n\n        if in_multi:\n            if prompt.strip() == end_token:\n                prompt = \"\\n\".join(accumulated)\n                fragments = accumulated_fragments\n                attachments = accumulated_attachments\n                in_multi = False\n                accumulated = []\n                accumulated_fragments = []\n                accumulated_attachments = []\n            else:\n                if prompt:\n                    accumulated.append(prompt)\n                accumulated_fragments += fragments\n                accumulated_attachments += attachments\n                continue\n        if template_obj:\n            try:\n                # Mirror prompt() logic: only pass input if template uses it\n                uses_input = \"input\" in template_obj.vars()\n                input_ = prompt if uses_input else \"\"\n                template_prompt, template_system = template_obj.evaluate(input_, params)\n            except Template.MissingVariables as ex:\n                raise click.ClickException(str(ex))\n            if template_system and not system:\n                system = template_system\n            if template_prompt:\n                if prompt and not uses_input:\n                    prompt = f\"{template_prompt}\\n{prompt}\"\n                else:\n                    prompt = template_prompt\n        if prompt.strip() in (\"exit\", \"quit\"):\n            break\n\n        response = conversation.chain(\n            prompt,\n            fragments=fragments,\n            system_fragments=argument_system_fragments,\n            attachments=attachments,\n            system=system,\n            **kwargs,\n        )\n\n        # System prompt and system fragments only sent for the first message\n        system = None\n        argument_system_fragments = []\n        for chunk in response:\n            print(chunk, end=\"\")\n            sys.stdout.flush()\n        response.log_to_db(db)\n        print(\"\")\n\n\ndef load_conversation(\n    conversation_id: Optional[str],\n    async_=False,\n    database=None,\n) -> Optional[_BaseConversation]:\n    log_path = pathlib.Path(database) if database else logs_db_path()\n    db = sqlite_utils.Database(log_path)\n    migrate(db)\n    if conversation_id is None:\n        # Return the most recent conversation, or None if there are none\n        matches = list(db[\"conversations\"].rows_where(order_by=\"id desc\", limit=1))\n        if matches:\n            conversation_id = matches[0][\"id\"]\n        else:\n            return None\n    try:\n        row = cast(sqlite_utils.db.Table, db[\"conversations\"]).get(conversation_id)\n    except sqlite_utils.db.NotFoundError:\n        raise click.ClickException(\n            \"No conversation found with id={}\".format(conversation_id)\n        )\n    # Inflate that conversation\n    conversation_class = AsyncConversation if async_ else Conversation\n    response_class = AsyncResponse if async_ else Response\n    conversation = conversation_class.from_row(row)\n    for response in db[\"responses\"].rows_where(\n        \"conversation_id = ?\", [conversation_id]\n    ):\n        conversation.responses.append(response_class.from_row(db, response))\n    return conversation\n\n\n@cli.group(\n    cls=DefaultGroup,\n    default=\"list\",\n    default_if_no_args=True,\n)\ndef keys():\n    \"Manage stored API keys for different models\"\n\n\n@keys.command(name=\"list\")\ndef keys_list():\n    \"List names of all stored keys\"\n    path = user_dir() / \"keys.json\"\n    if not path.exists():\n        click.echo(\"No keys found\")\n        return\n    keys = json.loads(path.read_text())\n    for key in sorted(keys.keys()):\n        if key != \"// Note\":\n            click.echo(key)\n\n\n@keys.command(name=\"path\")\ndef keys_path_command():\n    \"Output the path to the keys.json file\"\n    click.echo(user_dir() / \"keys.json\")\n\n\n@keys.command(name=\"get\")\n@click.argument(\"name\")\ndef keys_get(name):\n    \"\"\"\n    Return the value of a stored key\n\n    Example usage:\n\n    \\b\n        export OPENAI_API_KEY=$(llm keys get openai)\n    \"\"\"\n    path = user_dir() / \"keys.json\"\n    if not path.exists():\n        raise click.ClickException(\"No keys found\")\n    keys = json.loads(path.read_text())\n    try:\n        click.echo(keys[name])\n    except KeyError:\n        raise click.ClickException(\"No key found with name '{}'\".format(name))\n\n\n@keys.command(name=\"set\")\n@click.argument(\"name\")\n@click.option(\"--value\", prompt=\"Enter key\", hide_input=True, help=\"Value to set\")\ndef keys_set(name, value):\n    \"\"\"\n    Save a key in the keys.json file\n\n    Example usage:\n\n    \\b\n        $ llm keys set openai\n        Enter key: ...\n    \"\"\"\n    default = {\"// Note\": \"This file stores secret API credentials. Do not share!\"}\n    path = user_dir() / \"keys.json\"\n    path.parent.mkdir(parents=True, exist_ok=True)\n    if not path.exists():\n        path.write_text(json.dumps(default))\n        path.chmod(0o600)\n    try:\n        current = json.loads(path.read_text())\n    except json.decoder.JSONDecodeError:\n        current = default\n    current[name] = value\n    path.write_text(json.dumps(current, indent=2) + \"\\n\")\n\n\n@cli.group(\n    cls=DefaultGroup,\n    default=\"list\",\n    default_if_no_args=True,\n)\ndef logs():\n    \"Tools for exploring logged prompts and responses\"\n\n\n@logs.command(name=\"path\")\ndef logs_path():\n    \"Output the path to the logs.db file\"\n    click.echo(logs_db_path())\n\n\n@logs.command(name=\"status\")\ndef logs_status():\n    \"Show current status of database logging\"\n    path = logs_db_path()\n    if not path.exists():\n        click.echo(\"No log database found at {}\".format(path))\n        return\n    if logs_on():\n        click.echo(\"Logging is ON for all prompts\".format())\n    else:\n        click.echo(\"Logging is OFF\".format())\n    db = sqlite_utils.Database(path)\n    migrate(db)\n    click.echo(\"Found log database at {}\".format(path))\n    click.echo(\"Number of conversations logged:\\t{}\".format(db[\"conversations\"].count))\n    click.echo(\"Number of responses logged:\\t{}\".format(db[\"responses\"].count))\n    click.echo(\n        \"Database file size: \\t\\t{}\".format(_human_readable_size(path.stat().st_size))\n    )\n\n\n@logs.command(name=\"backup\")\n@click.argument(\"path\", type=click.Path(dir_okay=True, writable=True))\ndef backup(path):\n    \"Backup your logs database to this file\"\n    logs_path = logs_db_path()\n    path = pathlib.Path(path)\n    db = sqlite_utils.Database(logs_path)\n    try:\n        db.execute(\"vacuum into ?\", [str(path)])\n    except Exception as ex:\n        raise click.ClickException(str(ex))\n    click.echo(\n        \"Backed up {} to {}\".format(_human_readable_size(path.stat().st_size), path)\n    )\n\n\n@logs.command(name=\"on\")\ndef logs_turn_on():\n    \"Turn on logging for all prompts\"\n    path = user_dir() / \"logs-off\"\n    if path.exists():\n        path.unlink()\n\n\n@logs.command(name=\"off\")\ndef logs_turn_off():\n    \"Turn off logging for all prompts\"\n    path = user_dir() / \"logs-off\"\n    path.touch()\n\n\nLOGS_COLUMNS = \"\"\"    responses.id,\n    responses.model,\n    responses.resolved_model,\n    responses.prompt,\n    responses.system,\n    responses.prompt_json,\n    responses.options_json,\n    responses.response,\n    responses.response_json,\n    responses.conversation_id,\n    responses.duration_ms,\n    responses.datetime_utc,\n    responses.input_tokens,\n    responses.output_tokens,\n    responses.token_details,\n    conversations.name as conversation_name,\n    conversations.model as conversation_model,\n    schemas.content as schema_json\"\"\"\n\nLOGS_SQL = \"\"\"\nselect\n{columns}\nfrom\n    responses\nleft join schemas on responses.schema_id = schemas.id\nleft join conversations on responses.conversation_id = conversations.id{extra_where}\norder by {order_by}{limit}\n\"\"\"\nLOGS_SQL_SEARCH = \"\"\"\nselect\n{columns}\nfrom\n    responses\nleft join schemas on responses.schema_id = schemas.id\nleft join conversations on responses.conversation_id = conversations.id\njoin responses_fts on responses_fts.rowid = responses.rowid\nwhere responses_fts match :query{extra_where}\norder by {order_by}{limit}\n\"\"\"\n\nATTACHMENTS_SQL = \"\"\"\nselect\n    response_id,\n    attachments.id,\n    attachments.type,\n    attachments.path,\n    attachments.url,\n    length(attachments.content) as content_length\nfrom attachments\njoin prompt_attachments\n    on attachments.id = prompt_attachments.attachment_id\nwhere prompt_attachments.response_id in ({})\norder by prompt_attachments.\"order\"\n\"\"\"\n\n\n@logs.command(name=\"list\")\n@click.option(\n    \"-n\",\n    \"--count\",\n    type=int,\n    default=None,\n    help=\"Number of entries to show - defaults to 3, use 0 for all\",\n)\n@click.option(\n    \"-p\",\n    \"--path\",\n    type=click.Path(readable=True, exists=True, dir_okay=False),\n    help=\"Path to log database\",\n    hidden=True,\n)\n@click.option(\n    \"-d\",\n    \"--database\",\n    type=click.Path(readable=True, exists=True, dir_okay=False),\n    help=\"Path to log database\",\n)\n@click.option(\"-m\", \"--model\", help=\"Filter by model or model alias\")\n@click.option(\"-q\", \"--query\", help=\"Search for logs matching this string\")\n@click.option(\n    \"fragments\",\n    \"--fragment\",\n    \"-f\",\n    help=\"Filter for prompts using these fragments\",\n    multiple=True,\n)\n@click.option(\n    \"tools\",\n    \"-T\",\n    \"--tool\",\n    multiple=True,\n    help=\"Filter for prompts with results from these tools\",\n)\n@click.option(\n    \"any_tools\",\n    \"--tools\",\n    is_flag=True,\n    help=\"Filter for prompts with results from any tools\",\n)\n@schema_option\n@click.option(\n    \"--schema-multi\",\n    help=\"JSON schema used for multiple results\",\n)\n@click.option(\n    \"-l\", \"--latest\", is_flag=True, help=\"Return latest results matching search query\"\n)\n@click.option(\n    \"--data\", is_flag=True, help=\"Output newline-delimited JSON data for schema\"\n)\n@click.option(\"--data-array\", is_flag=True, help=\"Output JSON array of data for schema\")\n@click.option(\"--data-key\", help=\"Return JSON objects from array in this key\")\n@click.option(\n    \"--data-ids\", is_flag=True, help=\"Attach corresponding IDs to JSON objects\"\n)\n@click.option(\"-t\", \"--truncate\", is_flag=True, help=\"Truncate long strings in output\")\n@click.option(\n    \"-s\", \"--short\", is_flag=True, help=\"Shorter YAML output with truncated prompts\"\n)\n@click.option(\"-u\", \"--usage\", is_flag=True, help=\"Include token usage\")\n@click.option(\"-r\", \"--response\", is_flag=True, help=\"Just output the last response\")\n@click.option(\"-x\", \"--extract\", is_flag=True, help=\"Extract first fenced code block\")\n@click.option(\n    \"extract_last\",\n    \"--xl\",\n    \"--extract-last\",\n    is_flag=True,\n    help=\"Extract last fenced code block\",\n)\n@click.option(\n    \"current_conversation\",\n    \"-c\",\n    \"--current\",\n    is_flag=True,\n    flag_value=-1,\n    help=\"Show logs from the current conversation\",\n)\n@click.option(\n    \"conversation_id\",\n    \"--cid\",\n    \"--conversation\",\n    help=\"Show logs for this conversation ID\",\n)\n@click.option(\"--id-gt\", help=\"Return responses with ID > this\")\n@click.option(\"--id-gte\", help=\"Return responses with ID >= this\")\n@click.option(\n    \"json_output\",\n    \"--json\",\n    is_flag=True,\n    help=\"Output logs as JSON\",\n)\n@click.option(\n    \"--expand\",\n    \"-e\",\n    is_flag=True,\n    help=\"Expand fragments to show their content\",\n)\ndef logs_list(\n    count,\n    path,\n    database,\n    model,\n    query,\n    fragments,\n    tools,\n    any_tools,\n    schema_input,\n    schema_multi,\n    latest,\n    data,\n    data_array,\n    data_key,\n    data_ids,\n    truncate,\n    short,\n    usage,\n    response,\n    extract,\n    extract_last,\n    current_conversation,\n    conversation_id,\n    id_gt,\n    id_gte,\n    json_output,\n    expand,\n):\n    \"Show logged prompts and their responses\"\n    if database and not path:\n        path = database\n    path = pathlib.Path(path or logs_db_path())\n    if not path.exists():\n        raise click.ClickException(\"No log database found at {}\".format(path))\n    db = sqlite_utils.Database(path)\n    migrate(db)\n\n    if schema_multi:\n        schema_input = schema_multi\n    schema = resolve_schema_input(db, schema_input, load_template)\n    if schema_multi:\n        schema = multi_schema(schema)\n\n    if short and (json_output or response):\n        invalid = \" or \".join(\n            [\n                flag[0]\n                for flag in ((\"--json\", json_output), (\"--response\", response))\n                if flag[1]\n            ]\n        )\n        raise click.ClickException(\"Cannot use --short and {} together\".format(invalid))\n\n    if response and not current_conversation and not conversation_id:\n        current_conversation = True\n\n    if current_conversation:\n        try:\n            conversation_id = next(\n                db.query(\n                    \"select conversation_id from responses order by id desc limit 1\"\n                )\n            )[\"conversation_id\"]\n        except StopIteration:\n            # No conversations yet\n            raise click.ClickException(\"No conversations found\")\n\n    # For --conversation set limit 0, if not explicitly set\n    if count is None:\n        if conversation_id:\n            count = 0\n        else:\n            count = 3\n\n    model_id = None\n    if model:\n        # Resolve alias, if any\n        try:\n            model_id = get_model(model).model_id\n        except UnknownModelError:\n            # Maybe they uninstalled a model, use the -m option as-is\n            model_id = model\n\n    sql = LOGS_SQL\n    order_by = \"responses.id desc\"\n    if query:\n        sql = LOGS_SQL_SEARCH\n        if not latest:\n            order_by = \"responses_fts.rank desc\"\n\n    limit = \"\"\n    if count is not None and count > 0:\n        limit = \" limit {}\".format(count)\n\n    sql_format = {\n        \"limit\": limit,\n        \"columns\": LOGS_COLUMNS,\n        \"extra_where\": \"\",\n        \"order_by\": order_by,\n    }\n    where_bits = []\n    sql_params = {\n        \"model\": model_id,\n        \"query\": query,\n        \"conversation_id\": conversation_id,\n        \"id_gt\": id_gt,\n        \"id_gte\": id_gte,\n    }\n    if model_id:\n        where_bits.append(\"responses.model = :model\")\n    if conversation_id:\n        where_bits.append(\"responses.conversation_id = :conversation_id\")\n    if id_gt:\n        where_bits.append(\"responses.id > :id_gt\")\n    if id_gte:\n        where_bits.append(\"responses.id >= :id_gte\")\n    if fragments:\n        # Resolve the fragments to their hashes\n        fragment_hashes = [\n            fragment.id() for fragment in resolve_fragments(db, fragments)\n        ]\n        exists_clauses = []\n\n        for i, fragment_hash in enumerate(fragment_hashes):\n            exists_clause = f\"\"\"\n            exists (\n                select 1 from prompt_fragments\n                where prompt_fragments.response_id = responses.id\n                and prompt_fragments.fragment_id in (\n                    select fragments.id from fragments\n                    where hash = :f{i}\n                )\n                union\n                select 1 from system_fragments\n                where system_fragments.response_id = responses.id\n                and system_fragments.fragment_id in (\n                    select fragments.id from fragments\n                    where hash = :f{i}\n                )\n            )\n            \"\"\"\n            exists_clauses.append(exists_clause)\n            sql_params[\"f{}\".format(i)] = fragment_hash\n\n        where_bits.append(\" and \".join(exists_clauses))\n\n    if any_tools:\n        # Any response that involved at least one tool result\n        where_bits.append(\"\"\"\n            exists (\n              select 1\n                from tool_results\n              where\n                tool_results.response_id = responses.id\n            )\n        \"\"\")\n    if tools:\n        tools_by_name = get_tools()\n        # Filter responses by tools (must have ALL of the named tools, including plugin)\n        tool_clauses = []\n        for i, tool_name in enumerate(tools):\n            try:\n                plugin_name = tools_by_name[tool_name].plugin\n            except KeyError:\n                raise click.ClickException(f\"Unknown tool: {tool_name}\")\n\n            tool_clauses.append(f\"\"\"\n            exists (\n              select 1\n                from tool_results\n                join tools on tools.id = tool_results.tool_id\n               where tool_results.response_id = responses.id\n                 and tools.name = :tool{i}\n                 and tools.plugin = :plugin{i}\n            )\n            \"\"\")\n            sql_params[f\"tool{i}\"] = tool_name\n            sql_params[f\"plugin{i}\"] = plugin_name\n\n        # AND means “must have all” — use OR instead if you want “any of”\n        where_bits.append(\" and \".join(tool_clauses))\n\n    schema_id = None\n    if schema:\n        schema_id = make_schema_id(schema)[0]\n        where_bits.append(\"responses.schema_id = :schema_id\")\n        sql_params[\"schema_id\"] = schema_id\n\n    if where_bits:\n        where_ = \" and \" if query else \" where \"\n        sql_format[\"extra_where\"] = where_ + \" and \".join(where_bits)\n\n    final_sql = sql.format(**sql_format)\n    rows = list(db.query(final_sql, sql_params))\n\n    # Reverse the order - we do this because we 'order by id desc limit 3' to get the\n    # 3 most recent results, but we still want to display them in chronological order\n    # ... except for searches where we don't do this\n    if not query and not data:\n        rows.reverse()\n\n    # Fetch any attachments\n    ids = [row[\"id\"] for row in rows]\n    attachments = list(db.query(ATTACHMENTS_SQL.format(\",\".join(\"?\" * len(ids))), ids))\n    attachments_by_id = {}\n    for attachment in attachments:\n        attachments_by_id.setdefault(attachment[\"response_id\"], []).append(attachment)\n\n    FRAGMENTS_SQL = \"\"\"\n    select\n        {table}.response_id,\n        fragments.hash,\n        fragments.id as fragment_id,\n        fragments.content,\n        (\n            select json_group_array(fragment_aliases.alias)\n            from fragment_aliases\n            where fragment_aliases.fragment_id = fragments.id\n        ) as aliases\n    from {table}\n    join fragments on {table}.fragment_id = fragments.id\n    where {table}.response_id in ({placeholders})\n    order by {table}.\"order\"\n    \"\"\"\n\n    # Fetch any prompt or system prompt fragments\n    prompt_fragments_by_id = {}\n    system_fragments_by_id = {}\n    for table, dictionary in (\n        (\"prompt_fragments\", prompt_fragments_by_id),\n        (\"system_fragments\", system_fragments_by_id),\n    ):\n        for fragment in db.query(\n            FRAGMENTS_SQL.format(placeholders=\",\".join(\"?\" * len(ids)), table=table),\n            ids,\n        ):\n            dictionary.setdefault(fragment[\"response_id\"], []).append(fragment)\n\n    if data or data_array or data_key or data_ids:\n        # Special case for --data to output valid JSON\n        to_output = []\n        for row in rows:\n            response = row[\"response\"] or \"\"\n            try:\n                decoded = json.loads(response)\n                new_items = []\n                if (\n                    isinstance(decoded, dict)\n                    and (data_key in decoded)\n                    and all(isinstance(item, dict) for item in decoded[data_key])\n                ):\n                    for item in decoded[data_key]:\n                        new_items.append(item)\n                else:\n                    new_items.append(decoded)\n                if data_ids:\n                    for item in new_items:\n                        item[find_unused_key(item, \"response_id\")] = row[\"id\"]\n                        item[find_unused_key(item, \"conversation_id\")] = row[\"id\"]\n                to_output.extend(new_items)\n            except ValueError:\n                pass\n        for line in output_rows_as_json(to_output, nl=not data_array, compact=True):\n            click.echo(line)\n        return\n\n    # Tool usage information\n    TOOLS_SQL = \"\"\"\n    SELECT responses.id,\n    -- Tools related to this response\n    COALESCE(\n        (SELECT json_group_array(json_object(\n            'id', t.id,\n            'hash', t.hash,\n            'name', t.name,\n            'description', t.description,\n            'input_schema', json(t.input_schema)\n        ))\n        FROM tools t\n        JOIN tool_responses tr ON t.id = tr.tool_id\n        WHERE tr.response_id = responses.id\n        ),\n        '[]'\n    ) AS tools,\n    -- Tool calls for this response\n    COALESCE(\n        (SELECT json_group_array(json_object(\n            'id', tc.id,\n            'tool_id', tc.tool_id,\n            'name', tc.name,\n            'arguments', json(tc.arguments),\n            'tool_call_id', tc.tool_call_id\n        ))\n        FROM tool_calls tc\n        WHERE tc.response_id = responses.id\n        ),\n        '[]'\n    ) AS tool_calls,\n    -- Tool results for this response\n    COALESCE(\n        (SELECT json_group_array(json_object(\n            'id', tr.id,\n            'tool_id', tr.tool_id,\n            'name', tr.name,\n            'output', tr.output,\n            'tool_call_id', tr.tool_call_id,\n            'exception', tr.exception,\n            'attachments', COALESCE(\n                (SELECT json_group_array(json_object(\n                    'id', a.id,\n                    'type', a.type,\n                    'path', a.path,\n                    'url', a.url,\n                    'content', a.content\n                ))\n                FROM tool_results_attachments tra\n                JOIN attachments a ON tra.attachment_id = a.id\n                WHERE tra.tool_result_id = tr.id\n                ),\n                '[]'\n            )\n        ))\n        FROM tool_results tr\n        WHERE tr.response_id = responses.id\n        ),\n        '[]'\n    ) AS tool_results\n    FROM responses\n    where id in ({placeholders})\n    \"\"\"\n    tool_info_by_id = {\n        row[\"id\"]: {\n            \"tools\": json.loads(row[\"tools\"]),\n            \"tool_calls\": json.loads(row[\"tool_calls\"]),\n            \"tool_results\": json.loads(row[\"tool_results\"]),\n        }\n        for row in db.query(\n            TOOLS_SQL.format(placeholders=\",\".join(\"?\" * len(ids))), ids\n        )\n    }\n\n    for row in rows:\n        if truncate:\n            row[\"prompt\"] = truncate_string(row[\"prompt\"] or \"\")\n            row[\"response\"] = truncate_string(row[\"response\"] or \"\")\n        # Add prompt and system fragments\n        for key in (\"prompt_fragments\", \"system_fragments\"):\n            row[key] = [\n                {\n                    \"hash\": fragment[\"hash\"],\n                    \"content\": (\n                        fragment[\"content\"]\n                        if expand\n                        else truncate_string(fragment[\"content\"])\n                    ),\n                    \"aliases\": json.loads(fragment[\"aliases\"]),\n                }\n                for fragment in (\n                    prompt_fragments_by_id.get(row[\"id\"], [])\n                    if key == \"prompt_fragments\"\n                    else system_fragments_by_id.get(row[\"id\"], [])\n                )\n            ]\n        # Either decode or remove all JSON keys\n        keys = list(row.keys())\n        for key in keys:\n            if key.endswith(\"_json\") and row[key] is not None:\n                if truncate:\n                    del row[key]\n                else:\n                    row[key] = json.loads(row[key])\n        row.update(tool_info_by_id[row[\"id\"]])\n\n    output = None\n    if json_output:\n        # Output as JSON if requested\n        for row in rows:\n            row[\"attachments\"] = [\n                {k: v for k, v in attachment.items() if k != \"response_id\"}\n                for attachment in attachments_by_id.get(row[\"id\"], [])\n            ]\n        output = json.dumps(list(rows), indent=2)\n    elif extract or extract_last:\n        # Extract and return first code block\n        for row in rows:\n            output = extract_fenced_code_block(row[\"response\"], last=extract_last)\n            if output is not None:\n                break\n    elif response:\n        # Just output the last response\n        if rows:\n            output = rows[-1][\"response\"]\n\n    if output is not None:\n        click.echo(output)\n    else:\n        # Output neatly formatted human-readable logs\n        def _display_fragments(fragments, title):\n            if not fragments:\n                return\n            if not expand:\n                content = \"\\n\".join(\n                    [\"- {}\".format(fragment[\"hash\"]) for fragment in fragments]\n                )\n            else:\n                # <details><summary> for each one\n                bits = []\n                for fragment in fragments:\n                    bits.append(\n                        \"<details><summary>{}</summary>\\n{}\\n</details>\".format(\n                            fragment[\"hash\"], maybe_fenced_code(fragment[\"content\"])\n                        )\n                    )\n                content = \"\\n\".join(bits)\n            click.echo(f\"\\n### {title}\\n\\n{content}\")\n\n        current_system = None\n        should_show_conversation = True\n        for row in rows:\n            if short:\n                system = truncate_string(\n                    row[\"system\"] or \"\", 120, normalize_whitespace=True\n                )\n                prompt = truncate_string(\n                    row[\"prompt\"] or \"\", 120, normalize_whitespace=True, keep_end=True\n                )\n                cid = row[\"conversation_id\"]\n                attachments = attachments_by_id.get(row[\"id\"])\n                obj = {\n                    \"model\": row[\"model\"],\n                    \"datetime\": row[\"datetime_utc\"].split(\".\")[0],\n                    \"conversation\": cid,\n                }\n                if row[\"tool_calls\"]:\n                    obj[\"tool_calls\"] = [\n                        \"{}({})\".format(\n                            tool_call[\"name\"], json.dumps(tool_call[\"arguments\"])\n                        )\n                        for tool_call in row[\"tool_calls\"]\n                    ]\n                if row[\"tool_results\"]:\n                    obj[\"tool_results\"] = [\n                        \"{}: {}\".format(\n                            tool_result[\"name\"], truncate_string(tool_result[\"output\"])\n                        )\n                        for tool_result in row[\"tool_results\"]\n                    ]\n                if system:\n                    obj[\"system\"] = system\n                if prompt:\n                    obj[\"prompt\"] = prompt\n                if attachments:\n                    items = []\n                    for attachment in attachments:\n                        details = {\"type\": attachment[\"type\"]}\n                        if attachment.get(\"path\"):\n                            details[\"path\"] = attachment[\"path\"]\n                        if attachment.get(\"url\"):\n                            details[\"url\"] = attachment[\"url\"]\n                        items.append(details)\n                    obj[\"attachments\"] = items\n                for key in (\"prompt_fragments\", \"system_fragments\"):\n                    obj[key] = [fragment[\"hash\"] for fragment in row[key]]\n                if usage and (row[\"input_tokens\"] or row[\"output_tokens\"]):\n                    usage_details = {\n                        \"input\": row[\"input_tokens\"],\n                        \"output\": row[\"output_tokens\"],\n                    }\n                    if row[\"token_details\"]:\n                        usage_details[\"details\"] = json.loads(row[\"token_details\"])\n                    obj[\"usage\"] = usage_details\n                click.echo(yaml.dump([obj], sort_keys=False).strip())\n                continue\n            # Not short, output Markdown\n            click.echo(\n                \"# {}{}\\n{}\".format(\n                    row[\"datetime_utc\"].split(\".\")[0],\n                    (\n                        \"    conversation: {} id: {}\".format(\n                            row[\"conversation_id\"], row[\"id\"]\n                        )\n                        if should_show_conversation\n                        else \"\"\n                    ),\n                    (\n                        (\n                            \"\\nModel: **{}**{}\\n\".format(\n                                row[\"model\"],\n                                (\n                                    \" (resolved: **{}**)\".format(row[\"resolved_model\"])\n                                    if row[\"resolved_model\"]\n                                    else \"\"\n                                ),\n                            )\n                        )\n                        if should_show_conversation\n                        else \"\"\n                    ),\n                )\n            )\n            # In conversation log mode only show it for the first one\n            if conversation_id:\n                should_show_conversation = False\n            click.echo(\"## Prompt\\n\\n{}\".format(row[\"prompt\"] or \"-- none --\"))\n            _display_fragments(row[\"prompt_fragments\"], \"Prompt fragments\")\n            if row[\"options_json\"]:\n                options = row[\"options_json\"]\n                if isinstance(options, str):\n                    options = json.loads(options)\n                if options:\n                    options_text = \"\\n\".join(\n                        \"- {}: {}\".format(key, value) for key, value in options.items()\n                    )\n                    click.echo(\"\\n## Options\\n\\n{}\".format(options_text))\n            if row[\"system\"] != current_system:\n                if row[\"system\"] is not None:\n                    click.echo(\"\\n## System\\n\\n{}\".format(row[\"system\"]))\n                current_system = row[\"system\"]\n            _display_fragments(row[\"system_fragments\"], \"System fragments\")\n            if row[\"schema_json\"]:\n                click.echo(\n                    \"\\n## Schema\\n\\n```json\\n{}\\n```\".format(\n                        json.dumps(row[\"schema_json\"], indent=2)\n                    )\n                )\n            # Show tool calls and results\n            if row[\"tools\"]:\n                click.echo(\"\\n### Tools\\n\")\n                for tool in row[\"tools\"]:\n                    click.echo(\n                        \"- **{}**: `{}`<br>\\n    {}<br>\\n    Arguments: {}\".format(\n                            tool[\"name\"],\n                            tool[\"hash\"],\n                            tool[\"description\"],\n                            json.dumps(tool[\"input_schema\"][\"properties\"]),\n                        )\n                    )\n            if row[\"tool_results\"]:\n                click.echo(\"\\n### Tool results\\n\")\n                for tool_result in row[\"tool_results\"]:\n                    attachments = \"\"\n                    for attachment in tool_result[\"attachments\"]:\n                        desc = \"\"\n                        if attachment.get(\"type\"):\n                            desc += attachment[\"type\"] + \": \"\n                        if attachment.get(\"path\"):\n                            desc += attachment[\"path\"]\n                        elif attachment.get(\"url\"):\n                            desc += attachment[\"url\"]\n                        elif attachment.get(\"content\"):\n                            desc += f\"<{attachment['content_length']:,} bytes>\"\n                        attachments += \"\\n    - {}\".format(desc)\n                    click.echo(\n                        \"- **{}**: `{}`<br>\\n{}{}{}\".format(\n                            tool_result[\"name\"],\n                            tool_result[\"tool_call_id\"],\n                            textwrap.indent(tool_result[\"output\"], \"    \"),\n                            (\n                                \"<br>\\n    **Error**: {}\\n\".format(\n                                    tool_result[\"exception\"]\n                                )\n                                if tool_result[\"exception\"]\n                                else \"\"\n                            ),\n                            attachments,\n                        )\n                    )\n            attachments = attachments_by_id.get(row[\"id\"])\n            if attachments:\n                click.echo(\"\\n### Attachments\\n\")\n                for i, attachment in enumerate(attachments, 1):\n                    if attachment[\"path\"]:\n                        path = attachment[\"path\"]\n                        click.echo(\n                            \"{}. **{}**: `{}`\".format(i, attachment[\"type\"], path)\n                        )\n                    elif attachment[\"url\"]:\n                        click.echo(\n                            \"{}. **{}**: {}\".format(\n                                i, attachment[\"type\"], attachment[\"url\"]\n                            )\n                        )\n                    elif attachment[\"content_length\"]:\n                        click.echo(\n                            \"{}. **{}**: `<{} bytes>`\".format(\n                                i,\n                                attachment[\"type\"],\n                                f\"{attachment['content_length']:,}\",\n                            )\n                        )\n\n            # If a schema was provided and the row is valid JSON, pretty print and syntax highlight it\n            response = row[\"response\"]\n            if row[\"schema_json\"]:\n                try:\n                    parsed = json.loads(response)\n                    response = \"```json\\n{}\\n```\".format(json.dumps(parsed, indent=2))\n                except ValueError:\n                    pass\n            click.echo(\"\\n## Response\\n\")\n            if row[\"tool_calls\"]:\n                click.echo(\"### Tool calls\\n\")\n                for tool_call in row[\"tool_calls\"]:\n                    click.echo(\n                        \"- **{}**: `{}`<br>\\n    Arguments: {}\".format(\n                            tool_call[\"name\"],\n                            tool_call[\"tool_call_id\"],\n                            json.dumps(tool_call[\"arguments\"]),\n                        )\n                    )\n                click.echo(\"\")\n            if response:\n                click.echo(\"{}\\n\".format(response))\n            if usage:\n                token_usage = token_usage_string(\n                    row[\"input_tokens\"],\n                    row[\"output_tokens\"],\n                    json.loads(row[\"token_details\"]) if row[\"token_details\"] else None,\n                )\n                if token_usage:\n                    click.echo(\"## Token usage\\n\\n{}\\n\".format(token_usage))\n\n\n@cli.group(\n    cls=DefaultGroup,\n    default=\"list\",\n    default_if_no_args=True,\n)\ndef models():\n    \"Manage available models\"\n\n\n_type_lookup = {\n    \"number\": \"float\",\n    \"integer\": \"int\",\n    \"string\": \"str\",\n    \"object\": \"dict\",\n}\n\n\n@models.command(name=\"list\")\n@click.option(\n    \"--options\", is_flag=True, help=\"Show options for each model, if available\"\n)\n@click.option(\"async_\", \"--async\", is_flag=True, help=\"List async models\")\n@click.option(\"--schemas\", is_flag=True, help=\"List models that support schemas\")\n@click.option(\"--tools\", is_flag=True, help=\"List models that support tools\")\n@click.option(\n    \"-q\",\n    \"--query\",\n    multiple=True,\n    help=\"Search for models matching these strings\",\n)\n@click.option(\"model_ids\", \"-m\", \"--model\", help=\"Specific model IDs\", multiple=True)\ndef models_list(options, async_, schemas, tools, query, model_ids):\n    \"List available models\"\n    models_that_have_shown_options = set()\n    for model_with_aliases in get_models_with_aliases():\n        if async_ and not model_with_aliases.async_model:\n            continue\n        if query:\n            # Only show models where every provided query string matches\n            if not all(model_with_aliases.matches(q) for q in query):\n                continue\n        if model_ids:\n            ids_and_aliases = set(\n                [model_with_aliases.model.model_id] + model_with_aliases.aliases\n            )\n            if not ids_and_aliases.intersection(model_ids):\n                continue\n        if schemas and not model_with_aliases.model.supports_schema:\n            continue\n        if tools and not model_with_aliases.model.supports_tools:\n            continue\n        extra_info = []\n        if model_with_aliases.aliases:\n            extra_info.append(\n                \"aliases: {}\".format(\", \".join(model_with_aliases.aliases))\n            )\n        model = (\n            model_with_aliases.model if not async_ else model_with_aliases.async_model\n        )\n        output = str(model)\n        if extra_info:\n            output += \" ({})\".format(\", \".join(extra_info))\n        if options and model.Options.model_json_schema()[\"properties\"]:\n            output += \"\\n  Options:\"\n            for name, field in model.Options.model_json_schema()[\"properties\"].items():\n                any_of = field.get(\"anyOf\")\n                if any_of is None:\n                    any_of = [{\"type\": field.get(\"type\", \"str\")}]\n                types = \", \".join(\n                    [\n                        _type_lookup.get(item.get(\"type\"), item.get(\"type\", \"str\"))\n                        for item in any_of\n                        if item.get(\"type\") != \"null\"\n                    ]\n                )\n                bits = [\"\\n    \", name, \": \", types]\n                description = field.get(\"description\", \"\")\n                if description and (\n                    model.__class__ not in models_that_have_shown_options\n                ):\n                    wrapped = textwrap.wrap(description, 70)\n                    bits.append(\"\\n      \")\n                    bits.extend(\"\\n      \".join(wrapped))\n                output += \"\".join(bits)\n            models_that_have_shown_options.add(model.__class__)\n        if options and model.attachment_types:\n            attachment_types = \", \".join(sorted(model.attachment_types))\n            wrapper = textwrap.TextWrapper(\n                width=min(max(shutil.get_terminal_size().columns, 30), 70),\n                initial_indent=\"    \",\n                subsequent_indent=\"    \",\n            )\n            output += \"\\n  Attachment types:\\n{}\".format(wrapper.fill(attachment_types))\n        features = (\n            []\n            + ([\"streaming\"] if model.can_stream else [])\n            + ([\"schemas\"] if model.supports_schema else [])\n            + ([\"tools\"] if model.supports_tools else [])\n            + ([\"async\"] if model_with_aliases.async_model else [])\n        )\n        if options and features:\n            output += \"\\n  Features:\\n{}\".format(\n                \"\\n\".join(\"  - {}\".format(feature) for feature in features)\n            )\n        if options and hasattr(model, \"needs_key\") and model.needs_key:\n            output += \"\\n  Keys:\"\n            if hasattr(model, \"needs_key\") and model.needs_key:\n                output += \"\\n    key: {}\".format(model.needs_key)\n            if hasattr(model, \"key_env_var\") and model.key_env_var:\n                output += \"\\n    env_var: {}\".format(model.key_env_var)\n        click.echo(output)\n    if not query and not options and not schemas and not model_ids:\n        click.echo(f\"Default: {get_default_model()}\")\n\n\n@models.command(name=\"default\")\n@click.argument(\"model\", required=False)\ndef models_default(model):\n    \"Show or set the default model\"\n    if not model:\n        click.echo(get_default_model())\n        return\n    # Validate it is a known model\n    try:\n        model = get_model(model)\n        set_default_model(model.model_id)\n    except KeyError:\n        raise click.ClickException(\"Unknown model: {}\".format(model))\n\n\n@cli.group(\n    cls=DefaultGroup,\n    default=\"list\",\n    default_if_no_args=True,\n)\ndef templates():\n    \"Manage stored prompt templates\"\n\n\n@templates.command(name=\"list\")\ndef templates_list():\n    \"List available prompt templates\"\n    path = template_dir()\n    pairs = []\n    for file in path.glob(\"*.yaml\"):\n        name = file.stem\n        try:\n            template = load_template(name)\n        except LoadTemplateError:\n            # Skip invalid templates\n            continue\n        text = []\n        if template.system:\n            text.append(f\"system: {template.system}\")\n            if template.prompt:\n                text.append(f\" prompt: {template.prompt}\")\n        else:\n            text = [template.prompt if template.prompt else \"\"]\n        pairs.append((name, \"\".join(text).replace(\"\\n\", \" \")))\n    try:\n        max_name_len = max(len(p[0]) for p in pairs)\n    except ValueError:\n        return\n    else:\n        fmt = \"{name:<\" + str(max_name_len) + \"} : {prompt}\"\n        for name, prompt in sorted(pairs):\n            text = fmt.format(name=name, prompt=prompt)\n            click.echo(display_truncated(text))\n\n\n@templates.command(name=\"show\")\n@click.argument(\"name\")\ndef templates_show(name):\n    \"Show the specified prompt template\"\n    try:\n        template = load_template(name)\n    except LoadTemplateError:\n        raise click.ClickException(f\"Template '{name}' not found or invalid\")\n    click.echo(\n        yaml.dump(\n            dict((k, v) for k, v in template.model_dump().items() if v is not None),\n            indent=4,\n            default_flow_style=False,\n        )\n    )\n\n\n@templates.command(name=\"edit\")\n@click.argument(\"name\")\ndef templates_edit(name):\n    \"Edit the specified prompt template using the default $EDITOR\"\n    # First ensure it exists\n    path = template_dir() / f\"{name}.yaml\"\n    if not path.exists():\n        path.write_text(DEFAULT_TEMPLATE, \"utf-8\")\n    click.edit(filename=str(path))\n    # Validate that template\n    load_template(name)\n\n\n@templates.command(name=\"path\")\ndef templates_path():\n    \"Output the path to the templates directory\"\n    click.echo(template_dir())\n\n\n@templates.command(name=\"loaders\")\ndef templates_loaders():\n    \"Show template loaders registered by plugins\"\n    found = False\n    for prefix, loader in get_template_loaders().items():\n        found = True\n        docs = \"Undocumented\"\n        if loader.__doc__:\n            docs = textwrap.dedent(loader.__doc__).strip()\n        click.echo(f\"{prefix}:\")\n        click.echo(textwrap.indent(docs, \"  \"))\n    if not found:\n        click.echo(\"No template loaders found\")\n\n\n@cli.group(\n    cls=DefaultGroup,\n    default=\"list\",\n    default_if_no_args=True,\n)\ndef schemas():\n    \"Manage stored schemas\"\n\n\n@schemas.command(name=\"list\")\n@click.option(\n    \"-p\",\n    \"--path\",\n    type=click.Path(readable=True, exists=True, dir_okay=False),\n    help=\"Path to log database\",\n    hidden=True,\n)\n@click.option(\n    \"-d\",\n    \"--database\",\n    type=click.Path(readable=True, exists=True, dir_okay=False),\n    help=\"Path to log database\",\n)\n@click.option(\n    \"queries\",\n    \"-q\",\n    \"--query\",\n    multiple=True,\n    help=\"Search for schemas matching this string\",\n)\n@click.option(\"--full\", is_flag=True, help=\"Output full schema contents\")\n@click.option(\"json_\", \"--json\", is_flag=True, help=\"Output as JSON\")\n@click.option(\"nl\", \"--nl\", is_flag=True, help=\"Output as newline-delimited JSON\")\ndef schemas_list(path, database, queries, full, json_, nl):\n    \"List stored schemas\"\n    if database and not path:\n        path = database\n    path = pathlib.Path(path or logs_db_path())\n    if not path.exists():\n        raise click.ClickException(\"No log database found at {}\".format(path))\n    db = sqlite_utils.Database(path)\n    migrate(db)\n\n    params = []\n    where_sql = \"\"\n    if queries:\n        where_bits = [\"schemas.content like ?\" for _ in queries]\n        where_sql += \" where {}\".format(\" and \".join(where_bits))\n        params.extend(\"%{}%\".format(q) for q in queries)\n\n    sql = \"\"\"\n    select\n      schemas.id,\n      schemas.content,\n      max(responses.datetime_utc) as recently_used,\n      count(*) as times_used\n    from schemas\n    join responses\n      on responses.schema_id = schemas.id\n    {} group by responses.schema_id\n    order by recently_used\n    \"\"\".format(where_sql)\n    rows = db.query(sql, params)\n\n    if json_ or nl:\n        for line in output_rows_as_json(rows, json_cols={\"content\"}, nl=nl):\n            click.echo(line)\n        return\n\n    for row in rows:\n        click.echo(\"- id: {}\".format(row[\"id\"]))\n        if full:\n            click.echo(\n                \"  schema: |\\n{}\".format(\n                    textwrap.indent(\n                        json.dumps(json.loads(row[\"content\"]), indent=2), \"    \"\n                    )\n                )\n            )\n        else:\n            click.echo(\n                \"  summary: |\\n    {}\".format(\n                    schema_summary(json.loads(row[\"content\"]))\n                )\n            )\n        click.echo(\n            \"  usage: |\\n    {} time{}, most recently {}\".format(\n                row[\"times_used\"],\n                \"s\" if row[\"times_used\"] != 1 else \"\",\n                row[\"recently_used\"],\n            )\n        )\n\n\n@schemas.command(name=\"show\")\n@click.argument(\"schema_id\")\n@click.option(\n    \"-p\",\n    \"--path\",\n    type=click.Path(readable=True, exists=True, dir_okay=False),\n    help=\"Path to log database\",\n    hidden=True,\n)\n@click.option(\n    \"-d\",\n    \"--database\",\n    type=click.Path(readable=True, exists=True, dir_okay=False),\n    help=\"Path to log database\",\n)\ndef schemas_show(schema_id, path, database):\n    \"Show a stored schema\"\n    if database and not path:\n        path = database\n    path = pathlib.Path(path or logs_db_path())\n    if not path.exists():\n        raise click.ClickException(\"No log database found at {}\".format(path))\n    db = sqlite_utils.Database(path)\n    migrate(db)\n\n    try:\n        row = db[\"schemas\"].get(schema_id)\n    except sqlite_utils.db.NotFoundError:\n        raise click.ClickException(\"Invalid schema ID\")\n    click.echo(json.dumps(json.loads(row[\"content\"]), indent=2))\n\n\n@schemas.command(name=\"dsl\")\n@click.argument(\"input\")\n@click.option(\"--multi\", is_flag=True, help=\"Wrap in an array\")\ndef schemas_dsl_debug(input, multi):\n    \"\"\"\n    Convert LLM's schema DSL to a JSON schema\n\n    \\b\n        llm schema dsl 'name, age int, bio: their bio'\n    \"\"\"\n    schema = schema_dsl(input, multi)\n    click.echo(json.dumps(schema, indent=2))\n\n\n@cli.group(\n    cls=DefaultGroup,\n    default=\"list\",\n    default_if_no_args=True,\n)\ndef tools():\n    \"Manage tools that can be made available to LLMs\"\n\n\n@tools.command(name=\"list\")\n@click.argument(\"tool_defs\", nargs=-1)\n@click.option(\"json_\", \"--json\", is_flag=True, help=\"Output as JSON\")\n@click.option(\n    \"python_tools\",\n    \"--functions\",\n    help=\"Python code block or file path defining functions to register as tools\",\n    multiple=True,\n)\ndef tools_list(tool_defs, json_, python_tools):\n    \"List available tools that have been provided by plugins\"\n\n    def introspect_tools(toolbox_class):\n        methods = []\n        for tool in toolbox_class.method_tools():\n            methods.append(\n                {\n                    \"name\": tool.name,\n                    \"description\": tool.description,\n                    \"arguments\": tool.input_schema,\n                    \"implementation\": tool.implementation,\n                }\n            )\n        return methods\n\n    if tool_defs:\n        tools = {}\n        for tool in _gather_tools(tool_defs, python_tools):\n            if hasattr(tool, \"name\"):\n                tools[tool.name] = tool\n            else:\n                tools[tool.__class__.__name__] = tool\n    else:\n        tools = get_tools()\n        if python_tools:\n            for code_or_path in python_tools:\n                for tool in _tools_from_code(code_or_path):\n                    tools[tool.name] = tool\n\n    output_tools = []\n    output_toolboxes = []\n    tool_objects = []\n    toolbox_objects = []\n    for name, tool in sorted(tools.items()):\n        if isinstance(tool, Tool):\n            tool_objects.append(tool)\n            output_tools.append(\n                {\n                    \"name\": name,\n                    \"description\": tool.description,\n                    \"arguments\": tool.input_schema,\n                    \"plugin\": tool.plugin,\n                }\n            )\n        else:\n            toolbox_objects.append(tool)\n            output_toolboxes.append(\n                {\n                    \"name\": name,\n                    \"tools\": [\n                        {\n                            \"name\": tool[\"name\"],\n                            \"description\": tool[\"description\"],\n                            \"arguments\": tool[\"arguments\"],\n                        }\n                        for tool in introspect_tools(tool)\n                    ],\n                }\n            )\n    if json_:\n        click.echo(\n            json.dumps(\n                {\"tools\": output_tools, \"toolboxes\": output_toolboxes},\n                indent=2,\n            )\n        )\n    else:\n        for tool in tool_objects:\n            sig = \"()\"\n            if tool.implementation:\n                sig = str(inspect.signature(tool.implementation))\n            click.echo(\n                \"{}{}{}\\n\".format(\n                    tool.name,\n                    sig,\n                    \" (plugin: {})\".format(tool.plugin) if tool.plugin else \"\",\n                )\n            )\n            if tool.description:\n                click.echo(textwrap.indent(tool.description.strip(), \"  \") + \"\\n\")\n        for toolbox in toolbox_objects:\n            click.echo(toolbox.name + \":\\n\")\n            for tool in toolbox.method_tools():\n                sig = (\n                    str(inspect.signature(tool.implementation))\n                    .replace(\"(self, \", \"(\")\n                    .replace(\"(self)\", \"()\")\n                )\n                click.echo(\n                    \"  {}{}\\n\".format(\n                        tool.name,\n                        sig,\n                    )\n                )\n                if tool.description:\n                    click.echo(textwrap.indent(tool.description.strip(), \"    \") + \"\\n\")\n\n\n@cli.group(\n    cls=DefaultGroup,\n    default=\"list\",\n    default_if_no_args=True,\n)\ndef aliases():\n    \"Manage model aliases\"\n\n\n@aliases.command(name=\"list\")\n@click.option(\"json_\", \"--json\", is_flag=True, help=\"Output as JSON\")\ndef aliases_list(json_):\n    \"List current aliases\"\n    to_output = []\n    for alias, model in get_model_aliases().items():\n        if alias != model.model_id:\n            to_output.append((alias, model.model_id, \"\"))\n    for alias, embedding_model in get_embedding_model_aliases().items():\n        if alias != embedding_model.model_id:\n            to_output.append((alias, embedding_model.model_id, \"embedding\"))\n    if json_:\n        click.echo(\n            json.dumps({key: value for key, value, type_ in to_output}, indent=4)\n        )\n        return\n    max_alias_length = max(len(a) for a, _, _ in to_output)\n    fmt = \"{alias:<\" + str(max_alias_length) + \"} : {model_id}{type_}\"\n    for alias, model_id, type_ in to_output:\n        click.echo(\n            fmt.format(\n                alias=alias, model_id=model_id, type_=f\" ({type_})\" if type_ else \"\"\n            )\n        )\n\n\n@aliases.command(name=\"set\")\n@click.argument(\"alias\")\n@click.argument(\"model_id\", required=False)\n@click.option(\n    \"-q\",\n    \"--query\",\n    multiple=True,\n    help=\"Set alias for model matching these strings\",\n)\ndef aliases_set(alias, model_id, query):\n    \"\"\"\n    Set an alias for a model\n\n    Example usage:\n\n    \\b\n        llm aliases set mini gpt-4o-mini\n\n    Alternatively you can omit the model ID and specify one or more -q options.\n    The first model matching all of those query strings will be used.\n\n    \\b\n        llm aliases set mini -q 4o -q mini\n    \"\"\"\n    if not model_id:\n        if not query:\n            raise click.ClickException(\n                \"You must provide a model_id or at least one -q option\"\n            )\n        # Search for the first model matching all query strings\n        found = None\n        for model_with_aliases in get_models_with_aliases():\n            if all(model_with_aliases.matches(q) for q in query):\n                found = model_with_aliases\n                break\n        if not found:\n            raise click.ClickException(\n                \"No model found matching query: \" + \", \".join(query)\n            )\n        model_id = found.model.model_id\n        set_alias(alias, model_id)\n        click.echo(\n            f\"Alias '{alias}' set to model '{model_id}'\",\n            err=True,\n        )\n    else:\n        set_alias(alias, model_id)\n\n\n@aliases.command(name=\"remove\")\n@click.argument(\"alias\")\ndef aliases_remove(alias):\n    \"\"\"\n    Remove an alias\n\n    Example usage:\n\n    \\b\n        $ llm aliases remove turbo\n    \"\"\"\n    try:\n        remove_alias(alias)\n    except KeyError as ex:\n        raise click.ClickException(ex.args[0])\n\n\n@aliases.command(name=\"path\")\ndef aliases_path():\n    \"Output the path to the aliases.json file\"\n    click.echo(user_dir() / \"aliases.json\")\n\n\n@cli.group(\n    cls=DefaultGroup,\n    default=\"list\",\n    default_if_no_args=True,\n)\ndef fragments():\n    \"\"\"\n    Manage fragments that are stored in the database\n\n    Fragments are reusable snippets of text that are shared across multiple prompts.\n    \"\"\"\n\n\n@fragments.command(name=\"list\")\n@click.option(\n    \"queries\",\n    \"-q\",\n    \"--query\",\n    multiple=True,\n    help=\"Search for fragments matching these strings\",\n)\n@click.option(\"--aliases\", is_flag=True, help=\"Show only fragments with aliases\")\n@click.option(\"json_\", \"--json\", is_flag=True, help=\"Output as JSON\")\ndef fragments_list(queries, aliases, json_):\n    \"List current fragments\"\n    db = sqlite_utils.Database(logs_db_path())\n    migrate(db)\n    params = {}\n    param_count = 0\n    where_bits = []\n    if aliases:\n        where_bits.append(\"fragment_aliases.alias is not null\")\n    for q in queries:\n        param_count += 1\n        p = f\"p{param_count}\"\n        params[p] = q\n        where_bits.append(f\"\"\"\n            (fragments.hash = :{p} or fragment_aliases.alias = :{p}\n            or fragments.source like '%' || :{p} || '%'\n            or fragments.content like '%' || :{p} || '%')\n        \"\"\")\n    where = \"\\n      and\\n  \".join(where_bits)\n    if where:\n        where = \" where \" + where\n    sql = \"\"\"\n    select\n        fragments.hash,\n        json_group_array(fragment_aliases.alias) filter (\n            where\n            fragment_aliases.alias is not null\n        ) as aliases,\n        fragments.datetime_utc,\n        fragments.source,\n        fragments.content\n    from\n        fragments\n    left join\n        fragment_aliases on fragment_aliases.fragment_id = fragments.id\n    {where}\n    group by\n        fragments.id, fragments.hash, fragments.content, fragments.datetime_utc, fragments.source\n    order by fragments.datetime_utc\n    \"\"\".format(where=where)\n    results = list(db.query(sql, params))\n    for result in results:\n        result[\"aliases\"] = json.loads(result[\"aliases\"])\n    if json_:\n        click.echo(json.dumps(results, indent=4))\n    else:\n        yaml.add_representer(\n            str,\n            lambda dumper, data: dumper.represent_scalar(\n                \"tag:yaml.org,2002:str\", data, style=\"|\" if \"\\n\" in data else None\n            ),\n        )\n        for result in results:\n            result[\"content\"] = truncate_string(result[\"content\"])\n            click.echo(yaml.dump([result], sort_keys=False, width=sys.maxsize).strip())\n\n\n@fragments.command(name=\"set\")\n@click.argument(\"alias\", callback=validate_fragment_alias)\n@click.argument(\"fragment\")\ndef fragments_set(alias, fragment):\n    \"\"\"\n    Set an alias for a fragment\n\n    Accepts an alias and a file path, URL, hash or '-' for stdin\n\n    Example usage:\n\n    \\b\n        llm fragments set mydocs ./docs.md\n    \"\"\"\n    db = sqlite_utils.Database(logs_db_path())\n    migrate(db)\n    try:\n        resolved = resolve_fragments(db, [fragment])[0]\n    except FragmentNotFound as ex:\n        raise click.ClickException(str(ex))\n    migrate(db)\n    alias_sql = \"\"\"\n    insert into fragment_aliases (alias, fragment_id)\n    values (:alias, :fragment_id)\n    on conflict(alias) do update set\n        fragment_id = excluded.fragment_id;\n    \"\"\"\n    with db.conn:\n        fragment_id = ensure_fragment(db, resolved)\n        db.conn.execute(alias_sql, {\"alias\": alias, \"fragment_id\": fragment_id})\n\n\n@fragments.command(name=\"show\")\n@click.argument(\"alias_or_hash\")\ndef fragments_show(alias_or_hash):\n    \"\"\"\n    Display the fragment stored under an alias or hash\n\n    \\b\n        llm fragments show mydocs\n    \"\"\"\n    db = sqlite_utils.Database(logs_db_path())\n    migrate(db)\n    try:\n        resolved = resolve_fragments(db, [alias_or_hash])[0]\n    except FragmentNotFound as ex:\n        raise click.ClickException(str(ex))\n    click.echo(resolved)\n\n\n@fragments.command(name=\"remove\")\n@click.argument(\"alias\", callback=validate_fragment_alias)\ndef fragments_remove(alias):\n    \"\"\"\n    Remove a fragment alias\n\n    Example usage:\n\n    \\b\n        llm fragments remove docs\n    \"\"\"\n    db = sqlite_utils.Database(logs_db_path())\n    migrate(db)\n    with db.conn:\n        db.conn.execute(\n            \"delete from fragment_aliases where alias = :alias\", {\"alias\": alias}\n        )\n\n\n@fragments.command(name=\"loaders\")\ndef fragments_loaders():\n    \"\"\"Show fragment loaders registered by plugins\"\"\"\n    from llm import get_fragment_loaders\n\n    found = False\n    for prefix, loader in get_fragment_loaders().items():\n        if found:\n            # Extra newline on all after the first\n            click.echo(\"\")\n        found = True\n        docs = \"Undocumented\"\n        if loader.__doc__:\n            docs = textwrap.dedent(loader.__doc__).strip()\n        click.echo(f\"{prefix}:\")\n        click.echo(textwrap.indent(docs, \"  \"))\n    if not found:\n        click.echo(\"No fragment loaders found\")\n\n\n@cli.command(name=\"plugins\")\n@click.option(\"--all\", help=\"Include built-in default plugins\", is_flag=True)\n@click.option(\n    \"hooks\", \"--hook\", help=\"Filter for plugins that implement this hook\", multiple=True\n)\ndef plugins_list(all, hooks):\n    \"List installed plugins\"\n    plugins = get_plugins(all)\n    hooks = set(hooks)\n    if hooks:\n        plugins = [plugin for plugin in plugins if hooks.intersection(plugin[\"hooks\"])]\n    click.echo(json.dumps(plugins, indent=2))\n\n\ndef display_truncated(text):\n    console_width = shutil.get_terminal_size()[0]\n    if len(text) > console_width:\n        return text[: console_width - 3] + \"...\"\n    else:\n        return text\n\n\n@cli.command()\n@click.argument(\"packages\", nargs=-1, required=False)\n@click.option(\n    \"-U\", \"--upgrade\", is_flag=True, help=\"Upgrade packages to latest version\"\n)\n@click.option(\n    \"-e\",\n    \"--editable\",\n    help=\"Install a project in editable mode from this path\",\n)\n@click.option(\n    \"--force-reinstall\",\n    is_flag=True,\n    help=\"Reinstall all packages even if they are already up-to-date\",\n)\n@click.option(\n    \"--no-cache-dir\",\n    is_flag=True,\n    help=\"Disable the cache\",\n)\n@click.option(\n    \"--pre\",\n    is_flag=True,\n    help=\"Include pre-release and development versions\",\n)\ndef install(packages, upgrade, editable, force_reinstall, no_cache_dir, pre):\n    \"\"\"Install packages from PyPI into the same environment as LLM\"\"\"\n    args = [\"pip\", \"install\"]\n    if upgrade:\n        args += [\"--upgrade\"]\n    if editable:\n        args += [\"--editable\", editable]\n    if force_reinstall:\n        args += [\"--force-reinstall\"]\n    if no_cache_dir:\n        args += [\"--no-cache-dir\"]\n    if pre:\n        args += [\"--pre\"]\n    args += list(packages)\n    sys.argv = args\n    run_module(\"pip\", run_name=\"__main__\")\n\n\n@cli.command()\n@click.argument(\"packages\", nargs=-1, required=True)\n@click.option(\"-y\", \"--yes\", is_flag=True, help=\"Don't ask for confirmation\")\ndef uninstall(packages, yes):\n    \"\"\"Uninstall Python packages from the LLM environment\"\"\"\n    sys.argv = [\"pip\", \"uninstall\"] + list(packages) + ([\"-y\"] if yes else [])\n    run_module(\"pip\", run_name=\"__main__\")\n\n\n@cli.command()\n@click.argument(\"collection\", required=False)\n@click.argument(\"id\", required=False)\n@click.option(\n    \"-i\",\n    \"--input\",\n    type=click.Path(exists=True, readable=True, allow_dash=True),\n    help=\"File to embed\",\n)\n@click.option(\n    \"-m\", \"--model\", help=\"Embedding model to use\", envvar=\"LLM_EMBEDDING_MODEL\"\n)\n@click.option(\"--store\", is_flag=True, help=\"Store the text itself in the database\")\n@click.option(\n    \"-d\",\n    \"--database\",\n    type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True),\n    envvar=\"LLM_EMBEDDINGS_DB\",\n)\n@click.option(\n    \"-c\",\n    \"--content\",\n    help=\"Content to embed\",\n)\n@click.option(\"--binary\", is_flag=True, help=\"Treat input as binary data\")\n@click.option(\n    \"--metadata\",\n    help=\"JSON object metadata to store\",\n    callback=json_validator(\"metadata\"),\n)\n@click.option(\n    \"format_\",\n    \"-f\",\n    \"--format\",\n    type=click.Choice([\"json\", \"blob\", \"base64\", \"hex\"]),\n    help=\"Output format\",\n)\ndef embed(\n    collection, id, input, model, store, database, content, binary, metadata, format_\n):\n    \"\"\"Embed text and store or return the result\"\"\"\n    if collection and not id:\n        raise click.ClickException(\"Must provide both collection and id\")\n\n    if store and not collection:\n        raise click.ClickException(\"Must provide collection when using --store\")\n\n    # Lazy load this because we do not need it for -c or -i versions\n    def get_db():\n        if database:\n            return sqlite_utils.Database(database)\n        else:\n            return sqlite_utils.Database(user_dir() / \"embeddings.db\")\n\n    collection_obj = None\n    model_obj = None\n    if collection:\n        db = get_db()\n        if Collection.exists(db, collection):\n            # Load existing collection and use its model\n            collection_obj = Collection(collection, db)\n            model_obj = collection_obj.model()\n        else:\n            # We will create a new one, but that means model is required\n            if not model:\n                model = get_default_embedding_model()\n                if model is None:\n                    raise click.ClickException(\n                        \"You need to specify an embedding model (no default model is set)\"\n                    )\n            collection_obj = Collection(collection, db=db, model_id=model)\n            model_obj = collection_obj.model()\n\n    if model_obj is None:\n        if model is None:\n            model = get_default_embedding_model()\n        try:\n            model_obj = get_embedding_model(model)\n        except UnknownModelError:\n            raise click.ClickException(\n                \"You need to specify an embedding model (no default model is set)\"\n            )\n\n    show_output = True\n    if collection and (format_ is None):\n        show_output = False\n\n    # Resolve input text\n    if not content:\n        if not input or input == \"-\":\n            # Read from stdin\n            input_source = sys.stdin.buffer if binary else sys.stdin\n            content = input_source.read()\n        else:\n            mode = \"rb\" if binary else \"r\"\n            with open(input, mode) as f:\n                content = f.read()\n\n    if not content:\n        raise click.ClickException(\"No content provided\")\n\n    if collection_obj:\n        embedding = collection_obj.embed(id, content, metadata=metadata, store=store)\n    else:\n        embedding = model_obj.embed(content)\n\n    if show_output:\n        if format_ == \"json\" or format_ is None:\n            click.echo(json.dumps(embedding))\n        elif format_ == \"blob\":\n            click.echo(encode(embedding))\n        elif format_ == \"base64\":\n            click.echo(base64.b64encode(encode(embedding)).decode(\"ascii\"))\n        elif format_ == \"hex\":\n            click.echo(encode(embedding).hex())\n\n\n@cli.command()\n@click.argument(\"collection\")\n@click.argument(\n    \"input_path\",\n    type=click.Path(exists=True, dir_okay=False, allow_dash=True, readable=True),\n    required=False,\n)\n@click.option(\n    \"--format\",\n    type=click.Choice([\"json\", \"csv\", \"tsv\", \"nl\"]),\n    help=\"Format of input file - defaults to auto-detect\",\n)\n@click.option(\n    \"--files\",\n    type=(click.Path(file_okay=False, dir_okay=True, allow_dash=False), str),\n    multiple=True,\n    help=\"Embed files in this directory - specify directory and glob pattern\",\n)\n@click.option(\n    \"encodings\",\n    \"--encoding\",\n    help=\"Encodings to try when reading --files\",\n    multiple=True,\n)\n@click.option(\"--binary\", is_flag=True, help=\"Treat --files as binary data\")\n@click.option(\"--sql\", help=\"Read input using this SQL query\")\n@click.option(\n    \"--attach\",\n    type=(str, click.Path(file_okay=True, dir_okay=False, allow_dash=False)),\n    multiple=True,\n    help=\"Additional databases to attach - specify alias and file path\",\n)\n@click.option(\n    \"--batch-size\", type=int, help=\"Batch size to use when running embeddings\"\n)\n@click.option(\"--prefix\", help=\"Prefix to add to the IDs\", default=\"\")\n@click.option(\n    \"-m\", \"--model\", help=\"Embedding model to use\", envvar=\"LLM_EMBEDDING_MODEL\"\n)\n@click.option(\n    \"--prepend\",\n    help=\"Prepend this string to all content before embedding\",\n)\n@click.option(\"--store\", is_flag=True, help=\"Store the text itself in the database\")\n@click.option(\n    \"-d\",\n    \"--database\",\n    type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True),\n    envvar=\"LLM_EMBEDDINGS_DB\",\n)\ndef embed_multi(\n    collection,\n    input_path,\n    format,\n    files,\n    encodings,\n    binary,\n    sql,\n    attach,\n    batch_size,\n    prefix,\n    model,\n    prepend,\n    store,\n    database,\n):\n    \"\"\"\n    Store embeddings for multiple strings at once in the specified collection.\n\n    Input data can come from one of three sources:\n\n    \\b\n    1. A CSV, TSV, JSON or JSONL file:\n       - CSV/TSV: First column is ID, remaining columns concatenated as content\n       - JSON: Array of objects with \"id\" field and content fields\n       - JSONL: Newline-delimited JSON objects\n\n    \\b\n       Examples:\n         llm embed-multi docs input.csv\n         cat data.json | llm embed-multi docs -\n         llm embed-multi docs input.json --format json\n\n    \\b\n    2. A SQL query against a SQLite database:\n       - First column returned is used as ID\n       - Other columns concatenated to form content\n\n    \\b\n       Examples:\n         llm embed-multi docs --sql \"SELECT id, title, body FROM posts\"\n         llm embed-multi docs --attach blog blog.db --sql \"SELECT id, content FROM blog.posts\"\n\n    \\b\n    3. Files in directories matching glob patterns:\n       - Each file becomes one embedding\n       - Relative file paths become IDs\n\n    \\b\n       Examples:\n         llm embed-multi docs --files docs '**/*.md'\n         llm embed-multi images --files photos '*.jpg' --binary\n         llm embed-multi texts --files texts '*.txt' --encoding utf-8 --encoding latin-1\n    \"\"\"\n    if binary and not files:\n        raise click.UsageError(\"--binary must be used with --files\")\n    if binary and encodings:\n        raise click.UsageError(\"--binary cannot be used with --encoding\")\n    if not input_path and not sql and not files:\n        raise click.UsageError(\"Either --sql or input path or --files is required\")\n\n    if files:\n        if input_path or sql or format:\n            raise click.UsageError(\n                \"Cannot use --files with --sql, input path or --format\"\n            )\n\n    if database:\n        db = sqlite_utils.Database(database)\n    else:\n        db = sqlite_utils.Database(user_dir() / \"embeddings.db\")\n\n    for alias, attach_path in attach:\n        db.attach(alias, attach_path)\n\n    try:\n        collection_obj = Collection(\n            collection, db=db, model_id=model or get_default_embedding_model()\n        )\n    except ValueError:\n        raise click.ClickException(\n            \"You need to specify an embedding model (no default model is set)\"\n        )\n\n    expected_length = None\n    if files:\n        encodings = encodings or (\"utf-8\", \"latin-1\")\n\n        def count_files():\n            i = 0\n            for directory, pattern in files:\n                for path in pathlib.Path(directory).glob(pattern):\n                    i += 1\n            return i\n\n        def iterate_files():\n            for directory, pattern in files:\n                p = pathlib.Path(directory)\n                if not p.exists() or not p.is_dir():\n                    # fixes issue/274 - raise error if directory does not exist\n                    raise click.UsageError(f\"Invalid directory: {directory}\")\n                for path in pathlib.Path(directory).glob(pattern):\n                    if path.is_dir():\n                        continue  # fixed issue/280 - skip directories\n                    relative = path.relative_to(directory)\n                    content = None\n                    if binary:\n                        content = path.read_bytes()\n                    else:\n                        for encoding in encodings:\n                            try:\n                                content = path.read_text(encoding=encoding)\n                            except UnicodeDecodeError:\n                                continue\n                    if content is None:\n                        # Log to stderr\n                        click.echo(\n                            \"Could not decode text in file {}\".format(path),\n                            err=True,\n                        )\n                    else:\n                        yield {\"id\": str(relative), \"content\": content}\n\n        expected_length = count_files()\n        rows = iterate_files()\n    elif sql:\n        rows = db.query(sql)\n        count_sql = \"select count(*) as c from ({})\".format(sql)\n        expected_length = next(db.query(count_sql))[\"c\"]\n    else:\n\n        def load_rows(fp):\n            return rows_from_file(fp, Format[format.upper()] if format else None)[0]\n\n        try:\n            if input_path != \"-\":\n                # Read the file twice - first time is to get a count\n                expected_length = 0\n                with open(input_path, \"rb\") as fp:\n                    for _ in load_rows(fp):\n                        expected_length += 1\n\n            rows = load_rows(\n                open(input_path, \"rb\")\n                if input_path != \"-\"\n                else io.BufferedReader(sys.stdin.buffer)\n            )\n        except json.JSONDecodeError as ex:\n            raise click.ClickException(str(ex))\n\n    with click.progressbar(\n        rows, label=\"Embedding\", show_percent=True, length=expected_length\n    ) as rows:\n\n        def tuples() -> Iterable[Tuple[str, Union[bytes, str]]]:\n            for row in rows:\n                values = list(row.values())\n                id: str = prefix + str(values[0])\n                content: Optional[Union[bytes, str]] = None\n                if binary:\n                    content = cast(bytes, values[1])\n                else:\n                    content = \" \".join(v or \"\" for v in values[1:])\n                if prepend and isinstance(content, str):\n                    content = prepend + content\n                yield id, content or \"\"\n\n        embed_kwargs = {\"store\": store}\n        if batch_size:\n            embed_kwargs[\"batch_size\"] = batch_size\n        collection_obj.embed_multi(tuples(), **embed_kwargs)\n\n\n@cli.command()\n@click.argument(\"collection\")\n@click.argument(\"id\", required=False)\n@click.option(\n    \"-i\",\n    \"--input\",\n    type=click.Path(exists=True, readable=True, allow_dash=True),\n    help=\"File to embed for comparison\",\n)\n@click.option(\"-c\", \"--content\", help=\"Content to embed for comparison\")\n@click.option(\"--binary\", is_flag=True, help=\"Treat input as binary data\")\n@click.option(\n    \"-n\", \"--number\", type=int, default=10, help=\"Number of results to return\"\n)\n@click.option(\"-p\", \"--plain\", is_flag=True, help=\"Output in plain text format\")\n@click.option(\n    \"-d\",\n    \"--database\",\n    type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True),\n    envvar=\"LLM_EMBEDDINGS_DB\",\n)\n@click.option(\"--prefix\", help=\"Just IDs with this prefix\", default=\"\")\ndef similar(collection, id, input, content, binary, number, plain, database, prefix):\n    \"\"\"\n    Return top N similar IDs from a collection using cosine similarity.\n\n    Example usage:\n\n    \\b\n        llm similar my-collection -c \"I like cats\"\n\n    Or to find content similar to a specific stored ID:\n\n    \\b\n        llm similar my-collection 1234\n    \"\"\"\n    if not id and not content and not input:\n        raise click.ClickException(\"Must provide content or an ID for the comparison\")\n\n    if database:\n        db = sqlite_utils.Database(database)\n    else:\n        db = sqlite_utils.Database(user_dir() / \"embeddings.db\")\n\n    if not db[\"embeddings\"].exists():\n        raise click.ClickException(\"No embeddings table found in database\")\n\n    try:\n        collection_obj = Collection(collection, db, create=False)\n    except Collection.DoesNotExist:\n        raise click.ClickException(\"Collection does not exist\")\n\n    if id:\n        try:\n            results = collection_obj.similar_by_id(id, number, prefix=prefix)\n        except Collection.DoesNotExist:\n            raise click.ClickException(\"ID not found in collection\")\n    else:\n        # Resolve input text\n        if not content:\n            if not input or input == \"-\":\n                # Read from stdin\n                input_source = sys.stdin.buffer if binary else sys.stdin\n                content = input_source.read()\n            else:\n                mode = \"rb\" if binary else \"r\"\n                with open(input, mode) as f:\n                    content = f.read()\n        if not content:\n            raise click.ClickException(\"No content provided\")\n        results = collection_obj.similar(content, number, prefix=prefix)\n\n    for result in results:\n        if plain:\n            click.echo(f\"{result.id} ({result.score})\\n\")\n            if result.content:\n                click.echo(textwrap.indent(result.content, \"  \"))\n            if result.metadata:\n                click.echo(textwrap.indent(json.dumps(result.metadata), \"  \"))\n            click.echo(\"\")\n        else:\n            click.echo(json.dumps(asdict(result)))\n\n\n@cli.group(\n    cls=DefaultGroup,\n    default=\"list\",\n    default_if_no_args=True,\n)\ndef embed_models():\n    \"Manage available embedding models\"\n\n\n@embed_models.command(name=\"list\")\n@click.option(\n    \"-q\",\n    \"--query\",\n    multiple=True,\n    help=\"Search for embedding models matching these strings\",\n)\ndef embed_models_list(query):\n    \"List available embedding models\"\n    output = []\n    for model_with_aliases in get_embedding_models_with_aliases():\n        if query:\n            if not all(model_with_aliases.matches(q) for q in query):\n                continue\n        s = str(model_with_aliases.model)\n        if model_with_aliases.aliases:\n            s += \" (aliases: {})\".format(\", \".join(model_with_aliases.aliases))\n        output.append(s)\n    click.echo(\"\\n\".join(output))\n\n\n@embed_models.command(name=\"default\")\n@click.argument(\"model\", required=False)\n@click.option(\n    \"--remove-default\", is_flag=True, help=\"Reset to specifying no default model\"\n)\ndef embed_models_default(model, remove_default):\n    \"Show or set the default embedding model\"\n    if not model and not remove_default:\n        default = get_default_embedding_model()\n        if default is None:\n            click.echo(\"<No default embedding model set>\", err=True)\n        else:\n            click.echo(default)\n        return\n    # Validate it is a known model\n    try:\n        if remove_default:\n            set_default_embedding_model(None)\n        else:\n            model = get_embedding_model(model)\n            set_default_embedding_model(model.model_id)\n    except KeyError:\n        raise click.ClickException(\"Unknown embedding model: {}\".format(model))\n\n\n@cli.group(\n    cls=DefaultGroup,\n    default=\"list\",\n    default_if_no_args=True,\n)\ndef collections():\n    \"View and manage collections of embeddings\"\n\n\n@collections.command(name=\"path\")\ndef collections_path():\n    \"Output the path to the embeddings database\"\n    click.echo(user_dir() / \"embeddings.db\")\n\n\n@collections.command(name=\"list\")\n@click.option(\n    \"-d\",\n    \"--database\",\n    type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True),\n    envvar=\"LLM_EMBEDDINGS_DB\",\n    help=\"Path to embeddings database\",\n)\n@click.option(\"json_\", \"--json\", is_flag=True, help=\"Output as JSON\")\ndef embed_db_collections(database, json_):\n    \"View a list of collections\"\n    database = database or (user_dir() / \"embeddings.db\")\n    db = sqlite_utils.Database(str(database))\n    if not db[\"collections\"].exists():\n        raise click.ClickException(\"No collections table found in {}\".format(database))\n    rows = db.query(\"\"\"\n    select\n        collections.name,\n        collections.model,\n        count(embeddings.id) as num_embeddings\n    from\n        collections left join embeddings\n        on collections.id = embeddings.collection_id\n    group by\n        collections.name, collections.model\n    \"\"\")\n    if json_:\n        click.echo(json.dumps(list(rows), indent=4))\n    else:\n        for row in rows:\n            click.echo(\"{}: {}\".format(row[\"name\"], row[\"model\"]))\n            click.echo(\n                \"  {} embedding{}\".format(\n                    row[\"num_embeddings\"], \"s\" if row[\"num_embeddings\"] != 1 else \"\"\n                )\n            )\n\n\n@collections.command(name=\"delete\")\n@click.argument(\"collection\")\n@click.option(\n    \"-d\",\n    \"--database\",\n    type=click.Path(file_okay=True, allow_dash=False, dir_okay=False, writable=True),\n    envvar=\"LLM_EMBEDDINGS_DB\",\n    help=\"Path to embeddings database\",\n)\ndef collections_delete(collection, database):\n    \"\"\"\n    Delete the specified collection\n\n    Example usage:\n\n    \\b\n        llm collections delete my-collection\n    \"\"\"\n    database = database or (user_dir() / \"embeddings.db\")\n    db = sqlite_utils.Database(str(database))\n    try:\n        collection_obj = Collection(collection, db, create=False)\n    except Collection.DoesNotExist:\n        raise click.ClickException(\"Collection does not exist\")\n    collection_obj.delete()\n\n\n@models.group(\n    cls=DefaultGroup,\n    default=\"list\",\n    default_if_no_args=True,\n)\ndef options():\n    \"Manage default options for models\"\n\n\n@options.command(name=\"list\")\ndef options_list():\n    \"\"\"\n    List default options for all models\n\n    Example usage:\n\n    \\b\n        llm models options list\n    \"\"\"\n    options = get_all_model_options()\n    if not options:\n        click.echo(\"No default options set for any models.\", err=True)\n        return\n\n    for model_id, model_options in options.items():\n        click.echo(f\"{model_id}:\")\n        for key, value in model_options.items():\n            click.echo(f\"  {key}: {value}\")\n\n\n@options.command(name=\"show\")\n@click.argument(\"model\")\ndef options_show(model):\n    \"\"\"\n    List default options set for a specific model\n\n    Example usage:\n\n    \\b\n        llm models options show gpt-4o\n    \"\"\"\n    import llm\n\n    try:\n        # Resolve alias to model ID\n        model_obj = llm.get_model(model)\n        model_id = model_obj.model_id\n    except llm.UnknownModelError:\n        # Use as-is if not found\n        model_id = model\n\n    options = get_model_options(model_id)\n    if not options:\n        click.echo(f\"No default options set for model '{model_id}'.\", err=True)\n        return\n\n    for key, value in options.items():\n        click.echo(f\"{key}: {value}\")\n\n\n@options.command(name=\"set\")\n@click.argument(\"model\")\n@click.argument(\"key\")\n@click.argument(\"value\")\ndef options_set(model, key, value):\n    \"\"\"\n    Set a default option for a model\n\n    Example usage:\n\n    \\b\n        llm models options set gpt-4o temperature 0.5\n    \"\"\"\n    import llm\n\n    try:\n        # Resolve alias to model ID\n        model_obj = llm.get_model(model)\n        model_id = model_obj.model_id\n\n        # Validate option against model schema\n        try:\n            # Create a test Options object to validate\n            test_options = {key: value}\n            model_obj.Options(**test_options)\n        except pydantic.ValidationError as ex:\n            raise click.ClickException(render_errors(ex.errors()))\n\n    except llm.UnknownModelError:\n        # Use as-is if not found\n        model_id = model\n\n    set_model_option(model_id, key, value)\n    click.echo(f\"Set default option {key}={value} for model {model_id}\", err=True)\n\n\n@options.command(name=\"clear\")\n@click.argument(\"model\")\n@click.argument(\"key\", required=False)\ndef options_clear(model, key):\n    \"\"\"\n    Clear default option(s) for a model\n\n    Example usage:\n\n    \\b\n        llm models options clear gpt-4o\n        # Or for a single option\n        llm models options clear gpt-4o temperature\n    \"\"\"\n    import llm\n\n    try:\n        # Resolve alias to model ID\n        model_obj = llm.get_model(model)\n        model_id = model_obj.model_id\n    except llm.UnknownModelError:\n        # Use as-is if not found\n        model_id = model\n\n    cleared_keys = []\n    if not key:\n        cleared_keys = list(get_model_options(model_id).keys())\n        for key_ in cleared_keys:\n            clear_model_option(model_id, key_)\n    else:\n        cleared_keys.append(key)\n        clear_model_option(model_id, key)\n    if cleared_keys:\n        if len(cleared_keys) == 1:\n            click.echo(f\"Cleared option '{cleared_keys[0]}' for model {model_id}\")\n        else:\n            click.echo(\n                f\"Cleared {', '.join(cleared_keys)} options for model {model_id}\"\n            )\n\n\ndef template_dir():\n    path = user_dir() / \"templates\"\n    path.mkdir(parents=True, exist_ok=True)\n    return path\n\n\ndef logs_db_path():\n    return user_dir() / \"logs.db\"\n\n\ndef get_history(chat_id):\n    if chat_id is None:\n        return None, []\n    log_path = logs_db_path()\n    db = sqlite_utils.Database(log_path)\n    migrate(db)\n    if chat_id == -1:\n        # Return the most recent chat\n        last_row = list(db[\"logs\"].rows_where(order_by=\"-id\", limit=1))\n        if last_row:\n            chat_id = last_row[0].get(\"chat_id\") or last_row[0].get(\"id\")\n        else:  # Database is empty\n            return None, []\n    rows = db[\"logs\"].rows_where(\n        \"id = ? or chat_id = ?\", [chat_id, chat_id], order_by=\"id\"\n    )\n    return chat_id, rows\n\n\ndef render_errors(errors):\n    output = []\n    for error in errors:\n        output.append(\", \".join(error[\"loc\"]))\n        output.append(\"  \" + error[\"msg\"])\n    return \"\\n\".join(output)\n\n\nload_plugins()\n\npm.hook.register_commands(cli=cli)\n\n\ndef _human_readable_size(size_bytes):\n    if size_bytes == 0:\n        return \"0B\"\n\n    size_name = (\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\")\n    i = 0\n\n    while size_bytes >= 1024 and i < len(size_name) - 1:\n        size_bytes /= 1024.0\n        i += 1\n\n    return \"{:.2f}{}\".format(size_bytes, size_name[i])\n\n\ndef logs_on():\n    return not (user_dir() / \"logs-off\").exists()\n\n\ndef get_all_model_options() -> dict:\n    \"\"\"\n    Get all default options for all models\n    \"\"\"\n    path = user_dir() / \"model_options.json\"\n    if not path.exists():\n        return {}\n\n    try:\n        options = json.loads(path.read_text())\n    except json.JSONDecodeError:\n        return {}\n\n    return options\n\n\ndef get_model_options(model_id: str) -> dict:\n    \"\"\"\n    Get default options for a specific model\n\n    Args:\n        model_id: Return options for model with this ID\n\n    Returns:\n        A dictionary of model options\n    \"\"\"\n    path = user_dir() / \"model_options.json\"\n    if not path.exists():\n        return {}\n\n    try:\n        options = json.loads(path.read_text())\n    except json.JSONDecodeError:\n        return {}\n\n    return options.get(model_id, {})\n\n\ndef set_model_option(model_id: str, key: str, value: Any) -> None:\n    \"\"\"\n    Set a default option for a model.\n\n    Args:\n        model_id: The model ID\n        key: The option key\n        value: The option value\n    \"\"\"\n    path = user_dir() / \"model_options.json\"\n    if path.exists():\n        try:\n            options = json.loads(path.read_text())\n        except json.JSONDecodeError:\n            options = {}\n    else:\n        options = {}\n\n    # Ensure the model has an entry\n    if model_id not in options:\n        options[model_id] = {}\n\n    # Set the option\n    options[model_id][key] = value\n\n    # Save the options\n    path.write_text(json.dumps(options, indent=2))\n\n\ndef clear_model_option(model_id: str, key: str) -> None:\n    \"\"\"\n    Clear a model option\n\n    Args:\n        model_id: The model ID\n        key: Key to clear\n    \"\"\"\n    path = user_dir() / \"model_options.json\"\n    if not path.exists():\n        return\n\n    try:\n        options = json.loads(path.read_text())\n    except json.JSONDecodeError:\n        return\n\n    if model_id not in options:\n        return\n\n    if key in options[model_id]:\n        del options[model_id][key]\n        if not options[model_id]:\n            del options[model_id]\n\n    path.write_text(json.dumps(options, indent=2))\n\n\nclass LoadTemplateError(ValueError):\n    pass\n\n\ndef _parse_yaml_template(name, content):\n    try:\n        loaded = yaml.safe_load(content)\n    except yaml.YAMLError as ex:\n        raise LoadTemplateError(\"Invalid YAML: {}\".format(str(ex)))\n    if isinstance(loaded, str):\n        return Template(name=name, prompt=loaded)\n    loaded[\"name\"] = name\n    try:\n        return Template(**loaded)\n    except pydantic.ValidationError as ex:\n        msg = \"A validation error occurred:\\n\"\n        msg += render_errors(ex.errors())\n        raise LoadTemplateError(msg)\n\n\ndef load_template(name: str) -> Template:\n    \"Load template, or raise LoadTemplateError(msg)\"\n    if name.startswith(\"https://\") or name.startswith(\"http://\"):\n        response = httpx.get(name)\n        try:\n            response.raise_for_status()\n        except httpx.HTTPStatusError as ex:\n            raise LoadTemplateError(\"Could not load template {}: {}\".format(name, ex))\n        return _parse_yaml_template(name, response.text)\n\n    potential_path = pathlib.Path(name)\n\n    if has_plugin_prefix(name) and not potential_path.exists():\n        prefix, rest = name.split(\":\", 1)\n        loaders = get_template_loaders()\n        if prefix not in loaders:\n            raise LoadTemplateError(\"Unknown template prefix: {}\".format(prefix))\n        loader = loaders[prefix]\n        try:\n            return loader(rest)\n        except Exception as ex:\n            raise LoadTemplateError(\"Could not load template {}: {}\".format(name, ex))\n\n    # Try local file\n    if potential_path.exists():\n        path = potential_path\n    else:\n        # Look for template in template_dir()\n        path = template_dir() / f\"{name}.yaml\"\n    if not path.exists():\n        raise LoadTemplateError(f\"Invalid template: {name}\")\n    content = path.read_text()\n    template_obj = _parse_yaml_template(name, content)\n    # We trust functions here because they came from the filesystem\n    template_obj._functions_is_trusted = True\n    return template_obj\n\n\ndef _tools_from_code(code_or_path: str) -> List[Tool]:\n    \"\"\"\n    Treat all Python functions in the code as tools\n    \"\"\"\n    if \"\\n\" not in code_or_path and code_or_path.endswith(\".py\"):\n        try:\n            code_or_path = pathlib.Path(code_or_path).read_text()\n        except FileNotFoundError:\n            raise click.ClickException(\"File not found: {}\".format(code_or_path))\n    namespace: Dict[str, Any] = {}\n    tools = []\n    try:\n        exec(code_or_path, namespace)\n    except SyntaxError as ex:\n        raise click.ClickException(\"Error in --functions definition: {}\".format(ex))\n    # Register all callables in the locals dict:\n    for name, value in namespace.items():\n        if callable(value) and not name.startswith(\"_\"):\n            tools.append(Tool.function(value))\n    return tools\n\n\ndef _debug_tool_call(_, tool_call, tool_result):\n    click.echo(\n        click.style(\n            \"\\nTool call: {}({})\".format(tool_call.name, tool_call.arguments),\n            fg=\"yellow\",\n            bold=True,\n        ),\n        err=True,\n    )\n    output = \"\"\n    attachments = \"\"\n    if tool_result.attachments:\n        attachments += \"\\nAttachments:\\n\"\n        for attachment in tool_result.attachments:\n            attachments += f\"  {repr(attachment)}\\n\"\n\n    try:\n        output = json.dumps(json.loads(tool_result.output), indent=2)\n    except ValueError:\n        output = tool_result.output\n    output += attachments\n    click.echo(\n        click.style(\n            textwrap.indent(output, \"  \") + (\"\\n\" if not tool_result.exception else \"\"),\n            fg=\"green\",\n            bold=True,\n        ),\n        err=True,\n    )\n    if tool_result.exception:\n        click.echo(\n            click.style(\n                \"  Exception: {}\".format(tool_result.exception),\n                fg=\"red\",\n                bold=True,\n            ),\n            err=True,\n        )\n\n\ndef _approve_tool_call(_, tool_call):\n    click.echo(\n        click.style(\n            \"Tool call: {}({})\".format(tool_call.name, tool_call.arguments),\n            fg=\"yellow\",\n            bold=True,\n        ),\n        err=True,\n    )\n    if not click.confirm(\"Approve tool call?\"):\n        raise CancelToolCall(\"User cancelled tool call\")\n\n\ndef _gather_tools(\n    tool_specs: List[str], python_tools: List[str]\n) -> List[Union[Tool, Type[Toolbox]]]:\n    tools: List[Union[Tool, Type[Toolbox]]] = []\n    if python_tools:\n        for code_or_path in python_tools:\n            tools.extend(_tools_from_code(code_or_path))\n    registered_tools = get_tools()\n    registered_classes = dict(\n        (key, value)\n        for key, value in registered_tools.items()\n        if inspect.isclass(value)\n    )\n    bad_tools = [\n        tool for tool in tool_specs if tool.split(\"(\")[0] not in registered_tools\n    ]\n    if bad_tools:\n        raise click.ClickException(\n            \"Tool(s) {} not found. Available tools: {}\".format(\n                \", \".join(bad_tools), \", \".join(registered_tools.keys())\n            )\n        )\n    for tool_spec in tool_specs:\n        if not tool_spec[0].isupper():\n            # It's a function\n            tools.append(registered_tools[tool_spec])\n        else:\n            # It's a class\n            tools.append(instantiate_from_spec(registered_classes, tool_spec))\n    return tools\n\n\ndef _get_conversation_tools(conversation, tools):\n    if conversation and not tools and conversation.responses:\n        # Copy plugin tools from first response in conversation\n        initial_tools = conversation.responses[0].prompt.tools\n        if initial_tools:\n            # Only tools from plugins:\n            return [tool.name for tool in initial_tools if tool.plugin]\n"
  },
  {
    "path": "llm/default_plugins/__init__.py",
    "content": ""
  },
  {
    "path": "llm/default_plugins/default_tools.py",
    "content": "import llm\nfrom llm.tools import llm_time, llm_version\n\n\n@llm.hookimpl\ndef register_tools(register):\n    register(llm_version)\n    register(llm_time)\n"
  },
  {
    "path": "llm/default_plugins/openai_models.py",
    "content": "from llm import (\n    AsyncConversation,\n    AsyncKeyModel,\n    AsyncResponse,\n    Conversation,\n    EmbeddingModel,\n    KeyModel,\n    Prompt,\n    Response,\n    hookimpl,\n)\nimport llm\nfrom llm.utils import (\n    dicts_to_table_string,\n    remove_dict_none_values,\n    logging_client,\n    simplify_usage_dict,\n)\nimport click\nimport datetime\nfrom enum import Enum\nimport httpx\nimport openai\nimport os\n\nfrom pydantic import field_validator, Field\n\nfrom typing import AsyncGenerator, cast, List, Iterable, Iterator, Optional, Union\nimport json\nimport yaml\n\n\n@hookimpl\ndef register_models(register):\n    # GPT-4o\n    register(\n        Chat(\"gpt-4o\", vision=True, supports_schema=True, supports_tools=True),\n        AsyncChat(\"gpt-4o\", vision=True, supports_schema=True, supports_tools=True),\n        aliases=(\"4o\",),\n    )\n    register(\n        Chat(\"chatgpt-4o-latest\", vision=True),\n        AsyncChat(\"chatgpt-4o-latest\", vision=True),\n        aliases=(\"chatgpt-4o\",),\n    )\n    register(\n        Chat(\"gpt-4o-mini\", vision=True, supports_schema=True, supports_tools=True),\n        AsyncChat(\n            \"gpt-4o-mini\", vision=True, supports_schema=True, supports_tools=True\n        ),\n        aliases=(\"4o-mini\",),\n    )\n    for audio_model_id in (\n        \"gpt-4o-audio-preview\",\n        \"gpt-4o-audio-preview-2024-12-17\",\n        \"gpt-4o-audio-preview-2024-10-01\",\n        \"gpt-4o-mini-audio-preview\",\n        \"gpt-4o-mini-audio-preview-2024-12-17\",\n    ):\n        register(\n            Chat(audio_model_id, audio=True),\n            AsyncChat(audio_model_id, audio=True),\n        )\n    # GPT-4.1\n    for model_id in (\"gpt-4.1\", \"gpt-4.1-mini\", \"gpt-4.1-nano\"):\n        register(\n            Chat(model_id, vision=True, supports_schema=True, supports_tools=True),\n            AsyncChat(model_id, vision=True, supports_schema=True, supports_tools=True),\n            aliases=(model_id.replace(\"gpt-\", \"\"),),\n        )\n    # 3.5 and 4\n    register(\n        Chat(\"gpt-3.5-turbo\"), AsyncChat(\"gpt-3.5-turbo\"), aliases=(\"3.5\", \"chatgpt\")\n    )\n    register(\n        Chat(\"gpt-3.5-turbo-16k\"),\n        AsyncChat(\"gpt-3.5-turbo-16k\"),\n        aliases=(\"chatgpt-16k\", \"3.5-16k\"),\n    )\n    register(Chat(\"gpt-4\"), AsyncChat(\"gpt-4\"), aliases=(\"4\", \"gpt4\"))\n    register(Chat(\"gpt-4-32k\"), AsyncChat(\"gpt-4-32k\"), aliases=(\"4-32k\",))\n    # GPT-4 Turbo models\n    register(Chat(\"gpt-4-1106-preview\"), AsyncChat(\"gpt-4-1106-preview\"))\n    register(Chat(\"gpt-4-0125-preview\"), AsyncChat(\"gpt-4-0125-preview\"))\n    register(Chat(\"gpt-4-turbo-2024-04-09\"), AsyncChat(\"gpt-4-turbo-2024-04-09\"))\n    register(\n        Chat(\"gpt-4-turbo\"),\n        AsyncChat(\"gpt-4-turbo\"),\n        aliases=(\"gpt-4-turbo-preview\", \"4-turbo\", \"4t\"),\n    )\n    # GPT-4.5\n    register(\n        Chat(\n            \"gpt-4.5-preview-2025-02-27\",\n            vision=True,\n            supports_schema=True,\n            supports_tools=True,\n        ),\n        AsyncChat(\n            \"gpt-4.5-preview-2025-02-27\",\n            vision=True,\n            supports_schema=True,\n            supports_tools=True,\n        ),\n    )\n    register(\n        Chat(\"gpt-4.5-preview\", vision=True, supports_schema=True, supports_tools=True),\n        AsyncChat(\n            \"gpt-4.5-preview\", vision=True, supports_schema=True, supports_tools=True\n        ),\n        aliases=(\"gpt-4.5\",),\n    )\n    # o1\n    for model_id in (\"o1\", \"o1-2024-12-17\"):\n        register(\n            Chat(\n                model_id,\n                vision=True,\n                can_stream=False,\n                reasoning=True,\n                supports_schema=True,\n                supports_tools=True,\n            ),\n            AsyncChat(\n                model_id,\n                vision=True,\n                can_stream=False,\n                reasoning=True,\n                supports_schema=True,\n                supports_tools=True,\n            ),\n        )\n\n    register(\n        Chat(\"o1-preview\", allows_system_prompt=False),\n        AsyncChat(\"o1-preview\", allows_system_prompt=False),\n    )\n    register(\n        Chat(\"o1-mini\", allows_system_prompt=False),\n        AsyncChat(\"o1-mini\", allows_system_prompt=False),\n    )\n    register(\n        Chat(\"o3-mini\", reasoning=True, supports_schema=True, supports_tools=True),\n        AsyncChat(\"o3-mini\", reasoning=True, supports_schema=True, supports_tools=True),\n    )\n    register(\n        Chat(\n            \"o3\", vision=True, reasoning=True, supports_schema=True, supports_tools=True\n        ),\n        AsyncChat(\n            \"o3\", vision=True, reasoning=True, supports_schema=True, supports_tools=True\n        ),\n    )\n    register(\n        Chat(\n            \"o4-mini\",\n            vision=True,\n            reasoning=True,\n            supports_schema=True,\n            supports_tools=True,\n        ),\n        AsyncChat(\n            \"o4-mini\",\n            vision=True,\n            reasoning=True,\n            supports_schema=True,\n            supports_tools=True,\n        ),\n    )\n    # GPT-5\n    for model_id in (\n        \"gpt-5\",\n        \"gpt-5-mini\",\n        \"gpt-5-nano\",\n        \"gpt-5-2025-08-07\",\n        \"gpt-5-mini-2025-08-07\",\n        \"gpt-5-nano-2025-08-07\",\n    ):\n        register(\n            Chat(\n                model_id,\n                vision=True,\n                reasoning=True,\n                supports_schema=True,\n                supports_tools=True,\n            ),\n            AsyncChat(\n                model_id,\n                vision=True,\n                reasoning=True,\n                supports_schema=True,\n                supports_tools=True,\n            ),\n        )\n    # GPT-5.1\n    for model_id in (\n        \"gpt-5.1\",\n        \"gpt-5.1-chat-latest\",\n    ):\n        register(\n            Chat(\n                model_id,\n                vision=True,\n                reasoning=True,\n                supports_schema=True,\n                supports_tools=True,\n            ),\n            AsyncChat(\n                model_id,\n                vision=True,\n                reasoning=True,\n                supports_schema=True,\n                supports_tools=True,\n            ),\n        )\n    # GPT-5.2\n    for model_id in (\"gpt-5.2\", \"gpt-5.2-chat-latest\"):\n        register(\n            Chat(\n                model_id,\n                vision=True,\n                reasoning=True,\n                supports_schema=True,\n                supports_tools=True,\n            ),\n            AsyncChat(\n                model_id,\n                vision=True,\n                reasoning=True,\n                supports_schema=True,\n                supports_tools=True,\n            ),\n        )\n        # \"gpt-5.2-pro\" is Responses API only\n\n    # GPT-5.4\n    for model_id in (\n        \"gpt-5.4\",\n        \"gpt-5.4-2026-03-05\",\n        \"gpt-5.4-mini\",\n        \"gpt-5.4-mini-2026-03-17\",\n        \"gpt-5.4-nano\",\n        \"gpt-5.4-nano-2026-03-17\",\n    ):\n        register(\n            Chat(\n                model_id,\n                vision=True,\n                reasoning=True,\n                supports_schema=True,\n                supports_tools=True,\n            ),\n            AsyncChat(\n                model_id,\n                vision=True,\n                reasoning=True,\n                supports_schema=True,\n                supports_tools=True,\n            ),\n        )\n\n    # The -instruct completion model\n    register(\n        Completion(\"gpt-3.5-turbo-instruct\", default_max_tokens=256),\n        aliases=(\"3.5-instruct\", \"chatgpt-instruct\"),\n    )\n\n    # Load extra models\n    extra_path = llm.user_dir() / \"extra-openai-models.yaml\"\n    if not extra_path.exists():\n        return\n    with open(extra_path) as f:\n        extra_models = yaml.safe_load(f)\n    for extra_model in extra_models:\n        model_id = extra_model[\"model_id\"]\n        aliases = extra_model.get(\"aliases\", [])\n        model_name = extra_model[\"model_name\"]\n        api_base = extra_model.get(\"api_base\")\n        api_type = extra_model.get(\"api_type\")\n        api_version = extra_model.get(\"api_version\")\n        api_engine = extra_model.get(\"api_engine\")\n        headers = extra_model.get(\"headers\")\n        reasoning = extra_model.get(\"reasoning\")\n        kwargs = {}\n        if extra_model.get(\"can_stream\") is False:\n            kwargs[\"can_stream\"] = False\n        if extra_model.get(\"supports_schema\") is True:\n            kwargs[\"supports_schema\"] = True\n        if extra_model.get(\"supports_tools\") is True:\n            kwargs[\"supports_tools\"] = True\n        if extra_model.get(\"vision\") is True:\n            kwargs[\"vision\"] = True\n        if extra_model.get(\"audio\") is True:\n            kwargs[\"audio\"] = True\n        if extra_model.get(\"completion\"):\n            klass = Completion\n        else:\n            klass = Chat\n        chat_model = klass(\n            model_id,\n            model_name=model_name,\n            api_base=api_base,\n            api_type=api_type,\n            api_version=api_version,\n            api_engine=api_engine,\n            headers=headers,\n            reasoning=reasoning,\n            **kwargs,\n        )\n        if api_base:\n            chat_model.needs_key = None\n        if extra_model.get(\"api_key_name\"):\n            chat_model.needs_key = extra_model[\"api_key_name\"]\n        register(\n            chat_model,\n            aliases=aliases,\n        )\n\n\n@hookimpl\ndef register_embedding_models(register):\n    register(\n        OpenAIEmbeddingModel(\"text-embedding-ada-002\", \"text-embedding-ada-002\"),\n        aliases=(\n            \"ada\",\n            \"ada-002\",\n        ),\n    )\n    register(\n        OpenAIEmbeddingModel(\"text-embedding-3-small\", \"text-embedding-3-small\"),\n        aliases=(\"3-small\",),\n    )\n    register(\n        OpenAIEmbeddingModel(\"text-embedding-3-large\", \"text-embedding-3-large\"),\n        aliases=(\"3-large\",),\n    )\n    # With varying dimensions\n    register(\n        OpenAIEmbeddingModel(\n            \"text-embedding-3-small-512\", \"text-embedding-3-small\", 512\n        ),\n        aliases=(\"3-small-512\",),\n    )\n    register(\n        OpenAIEmbeddingModel(\n            \"text-embedding-3-large-256\", \"text-embedding-3-large\", 256\n        ),\n        aliases=(\"3-large-256\",),\n    )\n    register(\n        OpenAIEmbeddingModel(\n            \"text-embedding-3-large-1024\", \"text-embedding-3-large\", 1024\n        ),\n        aliases=(\"3-large-1024\",),\n    )\n\n\nclass OpenAIEmbeddingModel(EmbeddingModel):\n    needs_key = \"openai\"\n    key_env_var = \"OPENAI_API_KEY\"\n    batch_size = 100\n\n    def __init__(self, model_id, openai_model_id, dimensions=None):\n        self.model_id = model_id\n        self.openai_model_id = openai_model_id\n        self.dimensions = dimensions\n\n    def embed_batch(self, items: Iterable[Union[str, bytes]]) -> Iterator[List[float]]:\n        kwargs = {\n            \"input\": items,\n            \"model\": self.openai_model_id,\n        }\n        if self.dimensions:\n            kwargs[\"dimensions\"] = self.dimensions\n        client = openai.OpenAI(api_key=self.get_key())\n        results = client.embeddings.create(**kwargs).data\n        return ([float(r) for r in result.embedding] for result in results)\n\n\n@hookimpl\ndef register_commands(cli):\n    @cli.group(name=\"openai\")\n    def openai_():\n        \"Commands for working directly with the OpenAI API\"\n\n    @openai_.command()\n    @click.option(\"json_\", \"--json\", is_flag=True, help=\"Output as JSON\")\n    @click.option(\"--key\", help=\"OpenAI API key\")\n    def models(json_, key):\n        \"List models available to you from the OpenAI API\"\n        from llm import get_key\n\n        api_key = get_key(key, \"openai\", \"OPENAI_API_KEY\")\n        response = httpx.get(\n            \"https://api.openai.com/v1/models\",\n            headers={\"Authorization\": f\"Bearer {api_key}\"},\n        )\n        if response.status_code != 200:\n            raise click.ClickException(\n                f\"Error {response.status_code} from OpenAI API: {response.text}\"\n            )\n        models = response.json()[\"data\"]\n        if json_:\n            click.echo(json.dumps(models, indent=4))\n        else:\n            to_print = []\n            for model in models:\n                # Print id, owned_by, root, created as ISO 8601\n                created_str = datetime.datetime.fromtimestamp(\n                    model[\"created\"], datetime.timezone.utc\n                ).isoformat()\n                to_print.append(\n                    {\n                        \"id\": model[\"id\"],\n                        \"owned_by\": model[\"owned_by\"],\n                        \"created\": created_str,\n                    }\n                )\n            done = dicts_to_table_string(\"id owned_by created\".split(), to_print)\n            print(\"\\n\".join(done))\n\n\nclass SharedOptions(llm.Options):\n    temperature: Optional[float] = Field(\n        description=(\n            \"What sampling temperature to use, between 0 and 2. Higher values like \"\n            \"0.8 will make the output more random, while lower values like 0.2 will \"\n            \"make it more focused and deterministic.\"\n        ),\n        ge=0,\n        le=2,\n        default=None,\n    )\n    max_tokens: Optional[int] = Field(\n        description=\"Maximum number of tokens to generate.\", default=None\n    )\n    top_p: Optional[float] = Field(\n        description=(\n            \"An alternative to sampling with temperature, called nucleus sampling, \"\n            \"where the model considers the results of the tokens with top_p \"\n            \"probability mass. So 0.1 means only the tokens comprising the top \"\n            \"10% probability mass are considered. Recommended to use top_p or \"\n            \"temperature but not both.\"\n        ),\n        ge=0,\n        le=1,\n        default=None,\n    )\n    frequency_penalty: Optional[float] = Field(\n        description=(\n            \"Number between -2.0 and 2.0. Positive values penalize new tokens based \"\n            \"on their existing frequency in the text so far, decreasing the model's \"\n            \"likelihood to repeat the same line verbatim.\"\n        ),\n        ge=-2,\n        le=2,\n        default=None,\n    )\n    presence_penalty: Optional[float] = Field(\n        description=(\n            \"Number between -2.0 and 2.0. Positive values penalize new tokens based \"\n            \"on whether they appear in the text so far, increasing the model's \"\n            \"likelihood to talk about new topics.\"\n        ),\n        ge=-2,\n        le=2,\n        default=None,\n    )\n    stop: Optional[str] = Field(\n        description=(\"A string where the API will stop generating further tokens.\"),\n        default=None,\n    )\n    logit_bias: Optional[Union[dict, str]] = Field(\n        description=(\n            \"Modify the likelihood of specified tokens appearing in the completion. \"\n            'Pass a JSON string like \\'{\"1712\":-100, \"892\":-100, \"1489\":-100}\\''\n        ),\n        default=None,\n    )\n    seed: Optional[int] = Field(\n        description=\"Integer seed to attempt to sample deterministically\",\n        default=None,\n    )\n\n    @field_validator(\"logit_bias\")\n    def validate_logit_bias(cls, logit_bias):\n        if logit_bias is None:\n            return None\n\n        if isinstance(logit_bias, str):\n            try:\n                logit_bias = json.loads(logit_bias)\n            except json.JSONDecodeError:\n                raise ValueError(\"Invalid JSON in logit_bias string\")\n\n        validated_logit_bias = {}\n        for key, value in logit_bias.items():\n            try:\n                int_key = int(key)\n                int_value = int(value)\n                if -100 <= int_value <= 100:\n                    validated_logit_bias[int_key] = int_value\n                else:\n                    raise ValueError(\"Value must be between -100 and 100\")\n            except ValueError:\n                raise ValueError(\"Invalid key-value pair in logit_bias dictionary\")\n\n        return validated_logit_bias\n\n\nclass ReasoningEffortEnum(str, Enum):\n    none = \"none\"\n    minimal = \"minimal\"\n    low = \"low\"\n    medium = \"medium\"\n    high = \"high\"\n    xhigh = \"xhigh\"\n\n\nclass OptionsForReasoning(SharedOptions):\n    json_object: Optional[bool] = Field(\n        description=\"Output a valid JSON object {...}. Prompt must mention JSON.\",\n        default=None,\n    )\n    reasoning_effort: Optional[ReasoningEffortEnum] = Field(\n        description=(\n            \"Constraints effort on reasoning for reasoning models. Currently supported \"\n            \"values are low, medium, and high. Reducing reasoning effort can result in \"\n            \"faster responses and fewer tokens used on reasoning in a response.\"\n        ),\n        default=None,\n    )\n\n\ndef _attachment(attachment):\n    url = attachment.url\n    base64_content = \"\"\n    if not url or attachment.resolve_type().startswith(\"audio/\"):\n        base64_content = attachment.base64_content()\n        url = f\"data:{attachment.resolve_type()};base64,{base64_content}\"\n    if attachment.resolve_type() == \"application/pdf\":\n        if not base64_content:\n            base64_content = attachment.base64_content()\n        return {\n            \"type\": \"file\",\n            \"file\": {\n                \"filename\": f\"{attachment.id()}.pdf\",\n                \"file_data\": f\"data:application/pdf;base64,{base64_content}\",\n            },\n        }\n    if attachment.resolve_type().startswith(\"image/\"):\n        return {\"type\": \"image_url\", \"image_url\": {\"url\": url}}\n    else:\n        format_ = \"wav\" if attachment.resolve_type() == \"audio/wav\" else \"mp3\"\n        return {\n            \"type\": \"input_audio\",\n            \"input_audio\": {\n                \"data\": base64_content,\n                \"format\": format_,\n            },\n        }\n\n\nclass _Shared:\n    def __init__(\n        self,\n        model_id,\n        key=None,\n        model_name=None,\n        api_base=None,\n        api_type=None,\n        api_version=None,\n        api_engine=None,\n        headers=None,\n        can_stream=True,\n        vision=False,\n        audio=False,\n        reasoning=False,\n        supports_schema=False,\n        supports_tools=False,\n        allows_system_prompt=True,\n    ):\n        self.model_id = model_id\n        self.key = key\n        self.supports_schema = supports_schema\n        self.supports_tools = supports_tools\n        self.model_name = model_name\n        self.api_base = api_base\n        self.api_type = api_type\n        self.api_version = api_version\n        self.api_engine = api_engine\n        self.headers = headers\n        self.can_stream = can_stream\n        self.vision = vision\n        self.allows_system_prompt = allows_system_prompt\n\n        self.attachment_types = set()\n\n        if reasoning:\n            self.Options = OptionsForReasoning\n\n        if vision:\n            self.attachment_types.update(\n                {\n                    \"image/png\",\n                    \"image/jpeg\",\n                    \"image/webp\",\n                    \"image/gif\",\n                    \"application/pdf\",\n                }\n            )\n\n        if audio:\n            self.attachment_types.update(\n                {\n                    \"audio/wav\",\n                    \"audio/mpeg\",\n                }\n            )\n\n    def __str__(self) -> str:\n        return \"OpenAI Chat: {}\".format(self.model_id)\n\n    def build_messages(self, prompt, conversation):\n        messages = []\n        current_system = None\n        if conversation is not None:\n            for prev_response in conversation.responses:\n                if (\n                    prev_response.prompt.system\n                    and prev_response.prompt.system != current_system\n                ):\n                    messages.append(\n                        {\"role\": \"system\", \"content\": prev_response.prompt.system}\n                    )\n                    current_system = prev_response.prompt.system\n                if prev_response.attachments:\n                    attachment_message = []\n                    if prev_response.prompt.prompt:\n                        attachment_message.append(\n                            {\"type\": \"text\", \"text\": prev_response.prompt.prompt}\n                        )\n                    for attachment in prev_response.attachments:\n                        attachment_message.append(_attachment(attachment))\n                    messages.append({\"role\": \"user\", \"content\": attachment_message})\n                elif prev_response.prompt.prompt:\n                    messages.append(\n                        {\"role\": \"user\", \"content\": prev_response.prompt.prompt}\n                    )\n                for tool_result in prev_response.prompt.tool_results:\n                    messages.append(\n                        {\n                            \"role\": \"tool\",\n                            \"tool_call_id\": tool_result.tool_call_id,\n                            \"content\": tool_result.output,\n                        }\n                    )\n                prev_text = prev_response.text_or_raise()\n                if prev_text:\n                    messages.append({\"role\": \"assistant\", \"content\": prev_text})\n                tool_calls = prev_response.tool_calls_or_raise()\n                if tool_calls:\n                    messages.append(\n                        {\n                            \"role\": \"assistant\",\n                            \"tool_calls\": [\n                                {\n                                    \"type\": \"function\",\n                                    \"id\": tool_call.tool_call_id,\n                                    \"function\": {\n                                        \"name\": tool_call.name,\n                                        \"arguments\": json.dumps(tool_call.arguments),\n                                    },\n                                }\n                                for tool_call in tool_calls\n                            ],\n                        }\n                    )\n        if prompt.system and prompt.system != current_system:\n            messages.append({\"role\": \"system\", \"content\": prompt.system})\n        for tool_result in prompt.tool_results:\n            messages.append(\n                {\n                    \"role\": \"tool\",\n                    \"tool_call_id\": tool_result.tool_call_id,\n                    \"content\": tool_result.output,\n                }\n            )\n        if not prompt.attachments:\n            if prompt.prompt:\n                messages.append({\"role\": \"user\", \"content\": prompt.prompt or \"\"})\n        else:\n            attachment_message = []\n            if prompt.prompt:\n                attachment_message.append({\"type\": \"text\", \"text\": prompt.prompt})\n            for attachment in prompt.attachments:\n                attachment_message.append(_attachment(attachment))\n            messages.append({\"role\": \"user\", \"content\": attachment_message})\n        return messages\n\n    def set_usage(self, response, usage):\n        if not usage:\n            return\n        input_tokens = usage.pop(\"prompt_tokens\")\n        output_tokens = usage.pop(\"completion_tokens\")\n        usage.pop(\"total_tokens\")\n        response.set_usage(\n            input=input_tokens, output=output_tokens, details=simplify_usage_dict(usage)\n        )\n\n    def get_client(self, key, *, async_=False):\n        kwargs = {}\n        if self.api_base:\n            kwargs[\"base_url\"] = self.api_base\n        if self.api_type:\n            kwargs[\"api_type\"] = self.api_type\n        if self.api_version:\n            kwargs[\"api_version\"] = self.api_version\n        if self.api_engine:\n            kwargs[\"engine\"] = self.api_engine\n        if self.needs_key:\n            kwargs[\"api_key\"] = self.get_key(key)\n        else:\n            # OpenAI-compatible models don't need a key, but the\n            # openai client library requires one\n            kwargs[\"api_key\"] = \"DUMMY_KEY\"\n        if self.headers:\n            kwargs[\"default_headers\"] = self.headers\n        if os.environ.get(\"LLM_OPENAI_SHOW_RESPONSES\"):\n            kwargs[\"http_client\"] = logging_client()\n        if async_:\n            return openai.AsyncOpenAI(**kwargs)\n        else:\n            return openai.OpenAI(**kwargs)\n\n    def build_kwargs(self, prompt, stream):\n        kwargs = dict(not_nulls(prompt.options))\n        json_object = kwargs.pop(\"json_object\", None)\n        if \"max_tokens\" not in kwargs and self.default_max_tokens is not None:\n            kwargs[\"max_tokens\"] = self.default_max_tokens\n        if json_object:\n            kwargs[\"response_format\"] = {\"type\": \"json_object\"}\n        if prompt.schema:\n            kwargs[\"response_format\"] = {\n                \"type\": \"json_schema\",\n                \"json_schema\": {\"name\": \"output\", \"schema\": prompt.schema},\n            }\n        if prompt.tools:\n            kwargs[\"tools\"] = [\n                {\n                    \"type\": \"function\",\n                    \"function\": {\n                        \"name\": tool.name,\n                        \"description\": tool.description or None,\n                        \"parameters\": tool.input_schema,\n                    },\n                }\n                for tool in prompt.tools\n            ]\n        if stream:\n            kwargs[\"stream_options\"] = {\"include_usage\": True}\n        return kwargs\n\n\nclass Chat(_Shared, KeyModel):\n    needs_key = \"openai\"\n    key_env_var = \"OPENAI_API_KEY\"\n    default_max_tokens = None\n\n    class Options(SharedOptions):\n        json_object: Optional[bool] = Field(\n            description=\"Output a valid JSON object {...}. Prompt must mention JSON.\",\n            default=None,\n        )\n\n    def execute(\n        self,\n        prompt: Prompt,\n        stream: bool,\n        response: Response,\n        conversation: Optional[Conversation] = None,\n        key: Optional[str] = None,\n    ) -> Iterator[str]:\n        if prompt.system and not self.allows_system_prompt:\n            raise NotImplementedError(\"Model does not support system prompts\")\n        messages = self.build_messages(prompt, conversation)\n        kwargs = self.build_kwargs(prompt, stream)\n        client = self.get_client(key)\n        usage = None\n        if stream:\n            completion = client.chat.completions.create(\n                model=self.model_name or self.model_id,\n                messages=messages,\n                stream=True,\n                **kwargs,\n            )\n            chunks = []\n            tool_calls = {}\n            for chunk in completion:\n                chunks.append(chunk)\n                if chunk.usage:\n                    usage = chunk.usage.model_dump()\n                if chunk.choices and chunk.choices[0].delta:\n                    for tool_call in chunk.choices[0].delta.tool_calls or []:\n                        if tool_call.function.arguments is None:\n                            tool_call.function.arguments = \"\"\n                        index = tool_call.index\n                        if index not in tool_calls:\n                            tool_calls[index] = tool_call\n                        else:\n                            tool_calls[\n                                index\n                            ].function.arguments += tool_call.function.arguments\n                try:\n                    content = chunk.choices[0].delta.content\n                except IndexError:\n                    content = None\n                if content is not None:\n                    yield content\n            response.response_json = remove_dict_none_values(combine_chunks(chunks))\n            if tool_calls:\n                for value in tool_calls.values():\n                    # value.function looks like this:\n                    # ChoiceDeltaToolCallFunction(arguments='{\"city\":\"San Francisco\"}', name='get_weather')\n                    response.add_tool_call(\n                        llm.ToolCall(\n                            tool_call_id=value.id,\n                            name=value.function.name,\n                            arguments=json.loads(value.function.arguments),\n                        )\n                    )\n        else:\n            completion = client.chat.completions.create(\n                model=self.model_name or self.model_id,\n                messages=messages,\n                stream=False,\n                **kwargs,\n            )\n            usage = completion.usage.model_dump()\n            response.response_json = remove_dict_none_values(completion.model_dump())\n            for tool_call in completion.choices[0].message.tool_calls or []:\n                response.add_tool_call(\n                    llm.ToolCall(\n                        tool_call_id=tool_call.id,\n                        name=tool_call.function.name,\n                        arguments=json.loads(tool_call.function.arguments),\n                    )\n                )\n            if completion.choices[0].message.content is not None:\n                yield completion.choices[0].message.content\n        self.set_usage(response, usage)\n        response._prompt_json = redact_data({\"messages\": messages})\n\n\nclass AsyncChat(_Shared, AsyncKeyModel):\n    needs_key = \"openai\"\n    key_env_var = \"OPENAI_API_KEY\"\n    default_max_tokens = None\n\n    class Options(SharedOptions):\n        json_object: Optional[bool] = Field(\n            description=\"Output a valid JSON object {...}. Prompt must mention JSON.\",\n            default=None,\n        )\n\n    async def execute(\n        self,\n        prompt: Prompt,\n        stream: bool,\n        response: AsyncResponse,\n        conversation: Optional[AsyncConversation] = None,\n        key: Optional[str] = None,\n    ) -> AsyncGenerator[str, None]:\n        if prompt.system and not self.allows_system_prompt:\n            raise NotImplementedError(\"Model does not support system prompts\")\n        messages = self.build_messages(prompt, conversation)\n        kwargs = self.build_kwargs(prompt, stream)\n        client = self.get_client(key, async_=True)\n        usage = None\n        if stream:\n            completion = await client.chat.completions.create(\n                model=self.model_name or self.model_id,\n                messages=messages,\n                stream=True,\n                **kwargs,\n            )\n            chunks = []\n            tool_calls = {}\n            async for chunk in completion:\n                if chunk.usage:\n                    usage = chunk.usage.model_dump()\n                chunks.append(chunk)\n                if chunk.usage:\n                    usage = chunk.usage.model_dump()\n                if chunk.choices and chunk.choices[0].delta:\n                    for tool_call in chunk.choices[0].delta.tool_calls or []:\n                        if tool_call.function.arguments is None:\n                            tool_call.function.arguments = \"\"\n                        index = tool_call.index\n                        if index not in tool_calls:\n                            tool_calls[index] = tool_call\n                        else:\n                            tool_calls[\n                                index\n                            ].function.arguments += tool_call.function.arguments\n                try:\n                    content = chunk.choices[0].delta.content\n                except IndexError:\n                    content = None\n                if content is not None:\n                    yield content\n            if tool_calls:\n                for value in tool_calls.values():\n                    # value.function looks like this:\n                    # ChoiceDeltaToolCallFunction(arguments='{\"city\":\"San Francisco\"}', name='get_weather')\n                    response.add_tool_call(\n                        llm.ToolCall(\n                            tool_call_id=value.id,\n                            name=value.function.name,\n                            arguments=json.loads(value.function.arguments),\n                        )\n                    )\n            response.response_json = remove_dict_none_values(combine_chunks(chunks))\n        else:\n            completion = await client.chat.completions.create(\n                model=self.model_name or self.model_id,\n                messages=messages,\n                stream=False,\n                **kwargs,\n            )\n            response.response_json = remove_dict_none_values(completion.model_dump())\n            usage = completion.usage.model_dump()\n            for tool_call in completion.choices[0].message.tool_calls or []:\n                response.add_tool_call(\n                    llm.ToolCall(\n                        tool_call_id=tool_call.id,\n                        name=tool_call.function.name,\n                        arguments=json.loads(tool_call.function.arguments),\n                    )\n                )\n            if completion.choices[0].message.content is not None:\n                yield completion.choices[0].message.content\n        self.set_usage(response, usage)\n        response._prompt_json = redact_data({\"messages\": messages})\n\n\nclass Completion(Chat):\n    class Options(SharedOptions):\n        logprobs: Optional[int] = Field(\n            description=\"Include the log probabilities of most likely N per token\",\n            default=None,\n            le=5,\n        )\n\n    def __init__(self, *args, default_max_tokens=None, **kwargs):\n        super().__init__(*args, **kwargs)\n        self.default_max_tokens = default_max_tokens\n\n    def __str__(self) -> str:\n        return \"OpenAI Completion: {}\".format(self.model_id)\n\n    def execute(\n        self,\n        prompt: Prompt,\n        stream: bool,\n        response: Response,\n        conversation: Optional[Conversation] = None,\n        key: Optional[str] = None,\n    ) -> Iterator[str]:\n        if prompt.system:\n            raise NotImplementedError(\n                \"System prompts are not supported for OpenAI completion models\"\n            )\n        messages = []\n        if conversation is not None:\n            for prev_response in conversation.responses:\n                messages.append(prev_response.prompt.prompt)\n                messages.append(cast(Response, prev_response).text())\n        messages.append(prompt.prompt)\n        kwargs = self.build_kwargs(prompt, stream)\n        client = self.get_client(key)\n        if stream:\n            completion = client.completions.create(\n                model=self.model_name or self.model_id,\n                prompt=\"\\n\".join(messages),\n                stream=True,\n                **kwargs,\n            )\n            chunks = []\n            for chunk in completion:\n                chunks.append(chunk)\n                try:\n                    content = chunk.choices[0].text\n                except IndexError:\n                    content = None\n                if content is not None:\n                    yield content\n            combined = combine_chunks(chunks)\n            cleaned = remove_dict_none_values(combined)\n            response.response_json = cleaned\n        else:\n            completion = client.completions.create(\n                model=self.model_name or self.model_id,\n                prompt=\"\\n\".join(messages),\n                stream=False,\n                **kwargs,\n            )\n            response.response_json = remove_dict_none_values(completion.model_dump())\n            yield completion.choices[0].text\n        response._prompt_json = redact_data({\"messages\": messages})\n\n\ndef not_nulls(data) -> dict:\n    return {key: value for key, value in data if value is not None}\n\n\ndef combine_chunks(chunks: List) -> dict:\n    content = \"\"\n    role = None\n    finish_reason = None\n    # If any of them have log probability, we're going to persist\n    # those later on\n    logprobs = []\n    usage = {}\n\n    for item in chunks:\n        if item.usage:\n            usage = item.usage.model_dump()\n        for choice in item.choices:\n            if choice.logprobs and hasattr(choice.logprobs, \"top_logprobs\"):\n                logprobs.append(\n                    {\n                        \"text\": choice.text if hasattr(choice, \"text\") else None,\n                        \"top_logprobs\": choice.logprobs.top_logprobs,\n                    }\n                )\n\n            if not hasattr(choice, \"delta\"):\n                content += choice.text\n                continue\n            role = choice.delta.role\n            if choice.delta.content is not None:\n                content += choice.delta.content\n            if choice.finish_reason is not None:\n                finish_reason = choice.finish_reason\n\n    # Imitations of the OpenAI API may be missing some of these fields\n    combined = {\n        \"content\": content,\n        \"role\": role,\n        \"finish_reason\": finish_reason,\n        \"usage\": usage,\n    }\n    if logprobs:\n        combined[\"logprobs\"] = logprobs\n    if chunks:\n        for key in (\"id\", \"object\", \"model\", \"created\", \"index\"):\n            value = getattr(chunks[0], key, None)\n            if value is not None:\n                combined[key] = value\n\n    return combined\n\n\ndef redact_data(input_dict):\n    \"\"\"\n    Recursively search through the input dictionary for any 'image_url' keys\n    and modify the 'url' value to be just 'data:...'.\n\n    Also redact input_audio.data keys\n    \"\"\"\n    if isinstance(input_dict, dict):\n        for key, value in input_dict.items():\n            if (\n                key == \"image_url\"\n                and isinstance(value, dict)\n                and \"url\" in value\n                and value[\"url\"].startswith(\"data:\")\n            ):\n                value[\"url\"] = \"data:...\"\n            elif key == \"input_audio\" and isinstance(value, dict) and \"data\" in value:\n                value[\"data\"] = \"...\"\n            else:\n                redact_data(value)\n    elif isinstance(input_dict, list):\n        for item in input_dict:\n            redact_data(item)\n    return input_dict\n"
  },
  {
    "path": "llm/embeddings.py",
    "content": "from .models import EmbeddingModel\nfrom .embeddings_migrations import embeddings_migrations\nfrom dataclasses import dataclass\nimport hashlib\nfrom itertools import islice\nimport json\nfrom sqlite_utils import Database\nfrom sqlite_utils.db import Table\nimport time\nfrom typing import cast, Any, Dict, Iterable, List, Optional, Tuple, Union\n\n\n@dataclass\nclass Entry:\n    id: str\n    score: Optional[float]\n    content: Optional[str] = None\n    metadata: Optional[Dict[str, Any]] = None\n\n\nclass Collection:\n    class DoesNotExist(Exception):\n        pass\n\n    def __init__(\n        self,\n        name: str,\n        db: Optional[Database] = None,\n        *,\n        model: Optional[EmbeddingModel] = None,\n        model_id: Optional[str] = None,\n        create: bool = True,\n    ) -> None:\n        \"\"\"\n        A collection of embeddings\n\n        Returns the collection with the given name, creating it if it does not exist.\n\n        If you set create=False a Collection.DoesNotExist exception will be raised if the\n        collection does not already exist.\n\n        Args:\n            db (sqlite_utils.Database): Database to store the collection in\n            name (str): Name of the collection\n            model (llm.models.EmbeddingModel, optional): Embedding model to use\n            model_id (str, optional): Alternatively, ID of the embedding model to use\n            create (bool, optional): Whether to create the collection if it does not exist\n        \"\"\"\n        import llm\n\n        self.db = db or Database(memory=True)\n        self.name = name\n        self._model = model\n\n        embeddings_migrations.apply(self.db)\n\n        rows = list(self.db[\"collections\"].rows_where(\"name = ?\", [self.name]))\n        if rows:\n            row = rows[0]\n            self.id = row[\"id\"]\n            self.model_id = row[\"model\"]\n        else:\n            if create:\n                # Collection does not exist, so model or model_id is required\n                if not model and not model_id:\n                    raise ValueError(\n                        \"Either model= or model_id= must be provided when creating a new collection\"\n                    )\n                # Create it\n                if model_id:\n                    # Resolve alias\n                    model = llm.get_embedding_model(model_id)\n                    self._model = model\n                model_id = cast(EmbeddingModel, model).model_id\n                self.id = (\n                    cast(Table, self.db[\"collections\"])\n                    .insert(\n                        {\n                            \"name\": self.name,\n                            \"model\": model_id,\n                        }\n                    )\n                    .last_pk\n                )\n            else:\n                raise self.DoesNotExist(f\"Collection '{name}' does not exist\")\n\n    def model(self) -> EmbeddingModel:\n        \"Return the embedding model used by this collection\"\n        import llm\n\n        if self._model is None:\n            self._model = llm.get_embedding_model(self.model_id)\n\n        return cast(EmbeddingModel, self._model)\n\n    def count(self) -> int:\n        \"\"\"\n        Count the number of items in the collection.\n\n        Returns:\n            int: Number of items in the collection\n        \"\"\"\n        return next(\n            self.db.query(\n                \"\"\"\n            select count(*) as c from embeddings where collection_id = (\n                select id from collections where name = ?\n            )\n            \"\"\",\n                (self.name,),\n            )\n        )[\"c\"]\n\n    def embed(\n        self,\n        id: str,\n        value: Union[str, bytes],\n        metadata: Optional[Dict[str, Any]] = None,\n        store: bool = False,\n    ) -> None:\n        \"\"\"\n        Embed value and store it in the collection with a given ID.\n\n        Args:\n            id (str): ID for the value\n            value (str or bytes): value to be embedded\n            metadata (dict, optional): Metadata to be stored\n            store (bool, optional): Whether to store the value in the content or content_blob column\n        \"\"\"\n        from llm import encode\n\n        content_hash = self.content_hash(value)\n        if self.db[\"embeddings\"].count_where(\n            \"content_hash = ? and collection_id = ?\", [content_hash, self.id]\n        ):\n            return\n        embedding = self.model().embed(value)\n        cast(Table, self.db[\"embeddings\"]).insert(\n            {\n                \"collection_id\": self.id,\n                \"id\": id,\n                \"embedding\": encode(embedding),\n                \"content\": value if (store and isinstance(value, str)) else None,\n                \"content_blob\": value if (store and isinstance(value, bytes)) else None,\n                \"content_hash\": content_hash,\n                \"metadata\": json.dumps(metadata) if metadata else None,\n                \"updated\": int(time.time()),\n            },\n            replace=True,\n        )\n\n    def embed_multi(\n        self,\n        entries: Iterable[Tuple[str, Union[str, bytes]]],\n        store: bool = False,\n        batch_size: int = 100,\n    ) -> None:\n        \"\"\"\n        Embed multiple texts and store them in the collection with given IDs.\n\n        Args:\n            entries (iterable): Iterable of (id: str, text: str) tuples\n            store (bool, optional): Whether to store the text in the content column\n            batch_size (int, optional): custom maximum batch size to use\n        \"\"\"\n        self.embed_multi_with_metadata(\n            ((id, value, None) for id, value in entries),\n            store=store,\n            batch_size=batch_size,\n        )\n\n    def embed_multi_with_metadata(\n        self,\n        entries: Iterable[Tuple[str, Union[str, bytes], Optional[Dict[str, Any]]]],\n        store: bool = False,\n        batch_size: int = 100,\n    ) -> None:\n        \"\"\"\n        Embed multiple values along with metadata and store them in the collection with given IDs.\n\n        Args:\n            entries (iterable): Iterable of (id: str, value: str or bytes, metadata: None or dict)\n            store (bool, optional): Whether to store the value in the content or content_blob column\n            batch_size (int, optional): custom maximum batch size to use\n        \"\"\"\n        import llm\n\n        batch_size = min(batch_size, (self.model().batch_size or batch_size))\n        iterator = iter(entries)\n        collection_id = self.id\n        while True:\n            batch = list(islice(iterator, batch_size))\n            if not batch:\n                break\n            # Calculate hashes first\n            items_and_hashes = [(item, self.content_hash(item[1])) for item in batch]\n            # Any of those hashes already exist?\n            existing_ids = [\n                row[\"id\"]\n                for row in self.db.query(\n                    \"\"\"\n                    select id from embeddings\n                    where collection_id = ? and content_hash in ({})\n                    \"\"\".format(\",\".join(\"?\" for _ in items_and_hashes)),\n                    [collection_id]\n                    + [item_and_hash[1] for item_and_hash in items_and_hashes],\n                )\n            ]\n            filtered_batch = [item for item in batch if item[0] not in existing_ids]\n            embeddings = list(\n                self.model().embed_multi(item[1] for item in filtered_batch)\n            )\n            with self.db.conn:\n                cast(Table, self.db[\"embeddings\"]).insert_all(\n                    (\n                        {\n                            \"collection_id\": collection_id,\n                            \"id\": id,\n                            \"embedding\": llm.encode(embedding),\n                            \"content\": (\n                                value if (store and isinstance(value, str)) else None\n                            ),\n                            \"content_blob\": (\n                                value if (store and isinstance(value, bytes)) else None\n                            ),\n                            \"content_hash\": self.content_hash(value),\n                            \"metadata\": json.dumps(metadata) if metadata else None,\n                            \"updated\": int(time.time()),\n                        }\n                        for (embedding, (id, value, metadata)) in zip(\n                            embeddings, filtered_batch\n                        )\n                    ),\n                    replace=True,\n                )\n\n    def similar_by_vector(\n        self,\n        vector: List[float],\n        number: int = 10,\n        skip_id: Optional[str] = None,\n        prefix: Optional[str] = None,\n    ) -> List[Entry]:\n        \"\"\"\n        Find similar items in the collection by a given vector.\n\n        Args:\n            vector (list): Vector to search by\n            number (int, optional): Number of similar items to return\n            skip_id (str, optional): An ID to exclude from the results\n            prefix: (str, optional): Filter results to IDs witih this prefix\n\n        Returns:\n            list: List of Entry objects\n        \"\"\"\n        import llm\n\n        def distance_score(other_encoded):\n            other_vector = llm.decode(other_encoded)\n            return llm.cosine_similarity(other_vector, vector)\n\n        self.db.register_function(distance_score, replace=True)\n\n        where_bits = [\"collection_id = ?\"]\n        where_args = [str(self.id)]\n\n        if prefix:\n            where_bits.append(\"id LIKE ? || '%'\")\n            where_args.append(prefix)\n\n        if skip_id:\n            where_bits.append(\"id != ?\")\n            where_args.append(skip_id)\n\n        return [\n            Entry(\n                id=row[\"id\"],\n                score=row[\"score\"],\n                content=row[\"content\"],\n                metadata=json.loads(row[\"metadata\"]) if row[\"metadata\"] else None,\n            )\n            for row in self.db.query(\n                \"\"\"\n            select id, content, metadata, distance_score(embedding) as score\n            from embeddings\n            where {where}\n            order by score desc limit {number}\n        \"\"\".format(\n                    where=\" and \".join(where_bits),\n                    number=number,\n                ),\n                where_args,\n            )\n        ]\n\n    def similar_by_id(\n        self, id: str, number: int = 10, prefix: Optional[str] = None\n    ) -> List[Entry]:\n        \"\"\"\n        Find similar items in the collection by a given ID.\n\n        Args:\n            id (str): ID to search by\n            number (int, optional): Number of similar items to return\n            prefix: (str, optional): Filter results to IDs with this prefix\n\n        Returns:\n            list: List of Entry objects\n        \"\"\"\n        import llm\n\n        matches = list(\n            self.db[\"embeddings\"].rows_where(\n                \"collection_id = ? and id = ?\", (self.id, id)\n            )\n        )\n        if not matches:\n            raise self.DoesNotExist(\"ID not found\")\n        embedding = matches[0][\"embedding\"]\n        comparison_vector = llm.decode(embedding)\n        return self.similar_by_vector(\n            comparison_vector, number, skip_id=id, prefix=prefix\n        )\n\n    def similar(\n        self, value: Union[str, bytes], number: int = 10, prefix: Optional[str] = None\n    ) -> List[Entry]:\n        \"\"\"\n        Find similar items in the collection by a given value.\n\n        Args:\n            value (str or bytes): value to search by\n            number (int, optional): Number of similar items to return\n            prefix: (str, optional): Filter results to IDs with this prefix\n\n        Returns:\n            list: List of Entry objects\n        \"\"\"\n        comparison_vector = self.model().embed(value)\n        return self.similar_by_vector(comparison_vector, number, prefix=prefix)\n\n    @classmethod\n    def exists(cls, db: Database, name: str) -> bool:\n        \"\"\"\n        Does this collection exist in the database?\n\n        Args:\n            name (str): Name of the collection\n        \"\"\"\n        rows = list(db[\"collections\"].rows_where(\"name = ?\", [name]))\n        return bool(rows)\n\n    def delete(self):\n        \"\"\"\n        Delete the collection and its embeddings from the database\n        \"\"\"\n        with self.db.conn:\n            self.db.execute(\"delete from embeddings where collection_id = ?\", [self.id])\n            self.db.execute(\"delete from collections where id = ?\", [self.id])\n\n    @staticmethod\n    def content_hash(input: Union[str, bytes]) -> bytes:\n        \"Hash content for deduplication. Override to change hashing behavior.\"\n        if isinstance(input, str):\n            input = input.encode(\"utf8\")\n        return hashlib.md5(input).digest()\n"
  },
  {
    "path": "llm/embeddings_migrations.py",
    "content": "from sqlite_migrate import Migrations\nimport hashlib\nimport time\n\nembeddings_migrations = Migrations(\"llm.embeddings\")\n\n\n@embeddings_migrations()\ndef m001_create_tables(db):\n    db[\"collections\"].create({\"id\": int, \"name\": str, \"model\": str}, pk=\"id\")\n    db[\"collections\"].create_index([\"name\"], unique=True)\n    db[\"embeddings\"].create(\n        {\n            \"collection_id\": int,\n            \"id\": str,\n            \"embedding\": bytes,\n            \"content\": str,\n            \"metadata\": str,\n        },\n        pk=(\"collection_id\", \"id\"),\n    )\n\n\n@embeddings_migrations()\ndef m002_foreign_key(db):\n    db[\"embeddings\"].add_foreign_key(\"collection_id\", \"collections\", \"id\")\n\n\n@embeddings_migrations()\ndef m003_add_updated(db):\n    db[\"embeddings\"].add_column(\"updated\", int)\n    # Pretty-print the schema\n    db[\"embeddings\"].transform()\n    # Assume anything existing was last updated right now\n    db.query(\n        \"update embeddings set updated = ? where updated is null\", [int(time.time())]\n    )\n\n\n@embeddings_migrations()\ndef m004_store_content_hash(db):\n    db[\"embeddings\"].add_column(\"content_hash\", bytes)\n    db[\"embeddings\"].transform(\n        column_order=(\n            \"collection_id\",\n            \"id\",\n            \"embedding\",\n            \"content\",\n            \"content_hash\",\n            \"metadata\",\n            \"updated\",\n        )\n    )\n\n    # Register functions manually so we can de-register later\n    def md5(text):\n        return hashlib.md5(text.encode(\"utf8\")).digest()\n\n    def random_md5():\n        return hashlib.md5(str(time.time()).encode(\"utf8\")).digest()\n\n    db.conn.create_function(\"temp_md5\", 1, md5)\n    db.conn.create_function(\"temp_random_md5\", 0, random_md5)\n\n    with db.conn:\n        db.execute(\"\"\"\n            update embeddings\n            set content_hash = temp_md5(content)\n            where content is not null\n        \"\"\")\n        db.execute(\"\"\"\n            update embeddings\n            set content_hash = temp_random_md5()\n            where content is null\n        \"\"\")\n\n    db[\"embeddings\"].create_index([\"content_hash\"])\n\n    # De-register functions\n    db.conn.create_function(\"temp_md5\", 1, None)\n    db.conn.create_function(\"temp_random_md5\", 0, None)\n\n\n@embeddings_migrations()\ndef m005_add_content_blob(db):\n    db[\"embeddings\"].add_column(\"content_blob\", bytes)\n    db[\"embeddings\"].transform(\n        column_order=(\"collection_id\", \"id\", \"embedding\", \"content\", \"content_blob\")\n    )\n"
  },
  {
    "path": "llm/errors.py",
    "content": "class ModelError(Exception):\n    \"Models can raise this error, which will be displayed to the user\"\n\n\nclass NeedsKeyException(ModelError):\n    \"Model needs an API key which has not been provided\"\n"
  },
  {
    "path": "llm/hookspecs.py",
    "content": "from pluggy import HookimplMarker\nfrom pluggy import HookspecMarker\n\nhookspec = HookspecMarker(\"llm\")\nhookimpl = HookimplMarker(\"llm\")\n\n\n@hookspec\ndef register_commands(cli):\n    \"\"\"Register additional CLI commands, e.g. 'llm mycommand ...'\"\"\"\n\n\n@hookspec\ndef register_models(register):\n    \"Register additional model instances representing LLM models that can be called\"\n\n\n@hookspec\ndef register_embedding_models(register):\n    \"Register additional model instances that can be used for embedding\"\n\n\n@hookspec\ndef register_template_loaders(register):\n    \"Register additional template loaders with prefixes\"\n\n\n@hookspec\ndef register_fragment_loaders(register):\n    \"Register additional fragment loaders with prefixes\"\n\n\n@hookspec\ndef register_tools(register):\n    \"Register functions that can be used as tools by the LLMs\"\n"
  },
  {
    "path": "llm/migrations.py",
    "content": "import datetime\nfrom typing import Callable, List\n\nMIGRATIONS: List[Callable] = []\nmigration = MIGRATIONS.append\n\n\ndef migrate(db):\n    ensure_migrations_table(db)\n    already_applied = {r[\"name\"] for r in db[\"_llm_migrations\"].rows}\n    for fn in MIGRATIONS:\n        name = fn.__name__\n        if name not in already_applied:\n            fn(db)\n            db[\"_llm_migrations\"].insert(\n                {\n                    \"name\": name,\n                    \"applied_at\": str(datetime.datetime.now(datetime.timezone.utc)),\n                }\n            )\n            already_applied.add(name)\n\n\ndef ensure_migrations_table(db):\n    if not db[\"_llm_migrations\"].exists():\n        db[\"_llm_migrations\"].create(\n            {\n                \"name\": str,\n                \"applied_at\": str,\n            },\n            pk=\"name\",\n        )\n\n\n@migration\ndef m001_initial(db):\n    # Ensure the original table design exists, so other migrations can run\n    if db[\"log\"].exists():\n        # It needs to have the chat_id column\n        if \"chat_id\" not in db[\"log\"].columns_dict:\n            db[\"log\"].add_column(\"chat_id\")\n        return\n    db[\"log\"].create(\n        {\n            \"provider\": str,\n            \"system\": str,\n            \"prompt\": str,\n            \"chat_id\": str,\n            \"response\": str,\n            \"model\": str,\n            \"timestamp\": str,\n        }\n    )\n\n\n@migration\ndef m002_id_primary_key(db):\n    db[\"log\"].transform(pk=\"id\")\n\n\n@migration\ndef m003_chat_id_foreign_key(db):\n    db[\"log\"].transform(types={\"chat_id\": int})\n    db[\"log\"].add_foreign_key(\"chat_id\", \"log\", \"id\")\n\n\n@migration\ndef m004_column_order(db):\n    db[\"log\"].transform(\n        column_order=(\n            \"id\",\n            \"model\",\n            \"timestamp\",\n            \"prompt\",\n            \"system\",\n            \"response\",\n            \"chat_id\",\n        )\n    )\n\n\n@migration\ndef m004_drop_provider(db):\n    db[\"log\"].transform(drop=(\"provider\",))\n\n\n@migration\ndef m005_debug(db):\n    db[\"log\"].add_column(\"debug\", str)\n    db[\"log\"].add_column(\"duration_ms\", int)\n\n\n@migration\ndef m006_new_logs_table(db):\n    columns = db[\"log\"].columns_dict\n    for column, type in (\n        (\"options_json\", str),\n        (\"prompt_json\", str),\n        (\"response_json\", str),\n        (\"reply_to_id\", int),\n    ):\n        # It's possible people running development code like myself\n        # might have accidentally created these columns already\n        if column not in columns:\n            db[\"log\"].add_column(column, type)\n\n    # Use .transform() to rename options and timestamp_utc, and set new order\n    db[\"log\"].transform(\n        column_order=(\n            \"id\",\n            \"model\",\n            \"prompt\",\n            \"system\",\n            \"prompt_json\",\n            \"options_json\",\n            \"response\",\n            \"response_json\",\n            \"reply_to_id\",\n            \"chat_id\",\n            \"duration_ms\",\n            \"timestamp_utc\",\n        ),\n        rename={\n            \"timestamp\": \"timestamp_utc\",\n            \"options\": \"options_json\",\n        },\n    )\n\n\n@migration\ndef m007_finish_logs_table(db):\n    db[\"log\"].transform(\n        drop={\"debug\"},\n        rename={\"timestamp_utc\": \"datetime_utc\"},\n        drop_foreign_keys=(\"chat_id\",),\n    )\n    with db.conn:\n        db.execute(\"alter table log rename to logs\")\n\n\n@migration\ndef m008_reply_to_id_foreign_key(db):\n    db[\"logs\"].add_foreign_key(\"reply_to_id\", \"logs\", \"id\")\n\n\n@migration\ndef m008_fix_column_order_in_logs(db):\n    # reply_to_id ended up at the end after foreign key added\n    db[\"logs\"].transform(\n        column_order=(\n            \"id\",\n            \"model\",\n            \"prompt\",\n            \"system\",\n            \"prompt_json\",\n            \"options_json\",\n            \"response\",\n            \"response_json\",\n            \"reply_to_id\",\n            \"chat_id\",\n            \"duration_ms\",\n            \"timestamp_utc\",\n        ),\n    )\n\n\n@migration\ndef m009_delete_logs_table_if_empty(db):\n    # We moved to a new table design, but we don't delete the table\n    # if someone has put data in it\n    if not db[\"logs\"].count:\n        db[\"logs\"].drop()\n\n\n@migration\ndef m010_create_new_log_tables(db):\n    db[\"conversations\"].create(\n        {\n            \"id\": str,\n            \"name\": str,\n            \"model\": str,\n        },\n        pk=\"id\",\n    )\n    db[\"responses\"].create(\n        {\n            \"id\": str,\n            \"model\": str,\n            \"prompt\": str,\n            \"system\": str,\n            \"prompt_json\": str,\n            \"options_json\": str,\n            \"response\": str,\n            \"response_json\": str,\n            \"conversation_id\": str,\n            \"duration_ms\": int,\n            \"datetime_utc\": str,\n        },\n        pk=\"id\",\n        foreign_keys=((\"conversation_id\", \"conversations\", \"id\"),),\n    )\n\n\n@migration\ndef m011_fts_for_responses(db):\n    db[\"responses\"].enable_fts([\"prompt\", \"response\"], create_triggers=True)\n\n\n@migration\ndef m012_attachments_tables(db):\n    db[\"attachments\"].create(\n        {\n            \"id\": str,\n            \"type\": str,\n            \"path\": str,\n            \"url\": str,\n            \"content\": bytes,\n        },\n        pk=\"id\",\n    )\n    db[\"prompt_attachments\"].create(\n        {\n            \"response_id\": str,\n            \"attachment_id\": str,\n            \"order\": int,\n        },\n        foreign_keys=(\n            (\"response_id\", \"responses\", \"id\"),\n            (\"attachment_id\", \"attachments\", \"id\"),\n        ),\n        pk=(\"response_id\", \"attachment_id\"),\n    )\n\n\n@migration\ndef m013_usage(db):\n    db[\"responses\"].add_column(\"input_tokens\", int)\n    db[\"responses\"].add_column(\"output_tokens\", int)\n    db[\"responses\"].add_column(\"token_details\", str)\n\n\n@migration\ndef m014_schemas(db):\n    db[\"schemas\"].create(\n        {\n            \"id\": str,\n            \"content\": str,\n        },\n        pk=\"id\",\n    )\n    db[\"responses\"].add_column(\"schema_id\", str, fk=\"schemas\", fk_col=\"id\")\n    # Clean up SQL create table indentation\n    db[\"responses\"].transform()\n    # These changes may have dropped the FTS configuration, fix that\n    db[\"responses\"].enable_fts(\n        [\"prompt\", \"response\"], create_triggers=True, replace=True\n    )\n\n\n@migration\ndef m015_fragments_tables(db):\n    db[\"fragments\"].create(\n        {\n            \"id\": int,\n            \"hash\": str,\n            \"content\": str,\n            \"datetime_utc\": str,\n            \"source\": str,\n        },\n        pk=\"id\",\n    )\n    db[\"fragments\"].create_index([\"hash\"], unique=True)\n    db[\"fragment_aliases\"].create(\n        {\n            \"alias\": str,\n            \"fragment_id\": int,\n        },\n        foreign_keys=((\"fragment_id\", \"fragments\", \"id\"),),\n        pk=\"alias\",\n    )\n    db[\"prompt_fragments\"].create(\n        {\n            \"response_id\": str,\n            \"fragment_id\": int,\n            \"order\": int,\n        },\n        foreign_keys=(\n            (\"response_id\", \"responses\", \"id\"),\n            (\"fragment_id\", \"fragments\", \"id\"),\n        ),\n        pk=(\"response_id\", \"fragment_id\"),\n    )\n    db[\"system_fragments\"].create(\n        {\n            \"response_id\": str,\n            \"fragment_id\": int,\n            \"order\": int,\n        },\n        foreign_keys=(\n            (\"response_id\", \"responses\", \"id\"),\n            (\"fragment_id\", \"fragments\", \"id\"),\n        ),\n        pk=(\"response_id\", \"fragment_id\"),\n    )\n\n\n@migration\ndef m016_fragments_table_pks(db):\n    # The same fragment can be attached to a response multiple times\n    # https://github.com/simonw/llm/issues/863#issuecomment-2781720064\n    db[\"prompt_fragments\"].transform(pk=(\"response_id\", \"fragment_id\", \"order\"))\n    db[\"system_fragments\"].transform(pk=(\"response_id\", \"fragment_id\", \"order\"))\n\n\n@migration\ndef m017_tools_tables(db):\n    db[\"tools\"].create(\n        {\n            \"id\": int,\n            \"hash\": str,\n            \"name\": str,\n            \"description\": str,\n            \"input_schema\": str,\n        },\n        pk=\"id\",\n    )\n    db[\"tools\"].create_index([\"hash\"], unique=True)\n    # Many-to-many relationship between tools and responses\n    db[\"tool_responses\"].create(\n        {\n            \"tool_id\": int,\n            \"response_id\": str,\n        },\n        foreign_keys=(\n            (\"tool_id\", \"tools\", \"id\"),\n            (\"response_id\", \"responses\", \"id\"),\n        ),\n        pk=(\"tool_id\", \"response_id\"),\n    )\n    # tool_calls and tool_results are one-to-many against responses\n    db[\"tool_calls\"].create(\n        {\n            \"id\": int,\n            \"response_id\": str,\n            \"tool_id\": int,\n            \"name\": str,\n            \"arguments\": str,\n            \"tool_call_id\": str,\n        },\n        pk=\"id\",\n        foreign_keys=(\n            (\"response_id\", \"responses\", \"id\"),\n            (\"tool_id\", \"tools\", \"id\"),\n        ),\n    )\n    db[\"tool_results\"].create(\n        {\n            \"id\": int,\n            \"response_id\": str,\n            \"tool_id\": int,\n            \"name\": str,\n            \"output\": str,\n            \"tool_call_id\": str,\n        },\n        pk=\"id\",\n        foreign_keys=(\n            (\"response_id\", \"responses\", \"id\"),\n            (\"tool_id\", \"tools\", \"id\"),\n        ),\n    )\n\n\n@migration\ndef m017_tools_plugin(db):\n    db[\"tools\"].add_column(\"plugin\")\n\n\n@migration\ndef m018_tool_instances(db):\n    # Used to track instances of Toolbox classes that may be\n    # used multiple times by different tools\n    db[\"tool_instances\"].create(\n        {\n            \"id\": int,\n            \"plugin\": str,\n            \"name\": str,\n            \"arguments\": str,\n        },\n        pk=\"id\",\n    )\n    # We record which instance was used only on the results\n    db[\"tool_results\"].add_column(\"instance_id\", fk=\"tool_instances\")\n\n\n@migration\ndef m019_resolved_model(db):\n    # For models like gemini-1.5-flash-latest where we wish to record\n    # the resolved model name in addition to the alias\n    db[\"responses\"].add_column(\"resolved_model\", str)\n\n\n@migration\ndef m020_tool_results_attachments(db):\n    db[\"tool_results_attachments\"].create(\n        {\n            \"tool_result_id\": int,\n            \"attachment_id\": str,\n            \"order\": int,\n        },\n        foreign_keys=(\n            (\"tool_result_id\", \"tool_results\", \"id\"),\n            (\"attachment_id\", \"attachments\", \"id\"),\n        ),\n        pk=(\"tool_result_id\", \"attachment_id\"),\n    )\n\n\n@migration\ndef m021_tool_results_exception(db):\n    db[\"tool_results\"].add_column(\"exception\", str)\n"
  },
  {
    "path": "llm/models.py",
    "content": "import asyncio\nimport base64\nfrom condense_json import condense_json\nfrom dataclasses import dataclass, field\nimport datetime\nfrom .errors import NeedsKeyException\nimport hashlib\nimport httpx\nfrom itertools import islice\nfrom pathlib import Path\nimport re\nimport time\nfrom types import MethodType\nfrom typing import (\n    Any,\n    AsyncGenerator,\n    AsyncIterator,\n    Awaitable,\n    Callable,\n    Dict,\n    Iterable,\n    Iterator,\n    List,\n    Optional,\n    Set,\n    Union,\n    get_type_hints,\n)\nfrom .utils import (\n    ensure_fragment,\n    ensure_tool,\n    make_schema_id,\n    mimetype_from_path,\n    mimetype_from_string,\n    token_usage_string,\n    monotonic_ulid,\n    Fragment,\n)\nfrom abc import ABC, abstractmethod\nimport inspect\nimport json\nfrom pydantic import BaseModel, ConfigDict, create_model\n\nCONVERSATION_NAME_LENGTH = 32\n\n\n@dataclass\nclass Usage:\n    input: Optional[int] = None\n    output: Optional[int] = None\n    details: Optional[Dict[str, Any]] = None\n\n\n@dataclass\nclass Attachment:\n    type: Optional[str] = None\n    path: Optional[str] = None\n    url: Optional[str] = None\n    content: Optional[bytes] = None\n    _id: Optional[str] = None\n\n    def id(self):\n        # Hash of the binary content, or of '{\"url\": \"https://...\"}' for URL attachments\n        if self._id is None:\n            if self.content:\n                self._id = hashlib.sha256(self.content).hexdigest()\n            elif self.path:\n                self._id = hashlib.sha256(Path(self.path).read_bytes()).hexdigest()\n            else:\n                self._id = hashlib.sha256(\n                    json.dumps({\"url\": self.url}).encode(\"utf-8\")\n                ).hexdigest()\n        return self._id\n\n    def resolve_type(self):\n        if self.type:\n            return self.type\n        # Derive it from path or url or content\n        if self.path:\n            return mimetype_from_path(self.path)\n        if self.url:\n            response = httpx.head(self.url)\n            response.raise_for_status()\n            return response.headers.get(\"content-type\")\n        if self.content:\n            return mimetype_from_string(self.content)\n        raise ValueError(\"Attachment has no type and no content to derive it from\")\n\n    def content_bytes(self):\n        content = self.content\n        if not content:\n            if self.path:\n                content = Path(self.path).read_bytes()\n            elif self.url:\n                response = httpx.get(self.url)\n                response.raise_for_status()\n                content = response.content\n        return content\n\n    def base64_content(self):\n        return base64.b64encode(self.content_bytes()).decode(\"utf-8\")\n\n    def __repr__(self):\n        info = [f\"<Attachment: {self.id()}\"]\n        if self.type:\n            info.append(f'type=\"{self.type}\"')\n        if self.path:\n            info.append(f'path=\"{self.path}\"')\n        if self.url:\n            info.append(f'url=\"{self.url}\"')\n        if self.content:\n            info.append(f\"content={len(self.content)} bytes\")\n        return \" \".join(info) + \">\"\n\n    @classmethod\n    def from_row(cls, row):\n        return cls(\n            _id=row[\"id\"],\n            type=row[\"type\"],\n            path=row[\"path\"],\n            url=row[\"url\"],\n            content=row[\"content\"],\n        )\n\n\n@dataclass\nclass Tool:\n    name: str\n    description: Optional[str] = None\n    input_schema: Dict = field(default_factory=dict)\n    implementation: Optional[Callable] = None\n    plugin: Optional[str] = None  # plugin tool came from, e.g. 'llm_tools_sqlite'\n\n    def __post_init__(self):\n        # Convert Pydantic model to JSON schema if needed\n        self.input_schema = _ensure_dict_schema(self.input_schema)\n\n    def hash(self):\n        \"\"\"Hash for tool based on its name, description and input schema (preserving key order)\"\"\"\n        to_hash = {\n            \"name\": self.name,\n            \"description\": self.description,\n            \"input_schema\": self.input_schema,\n        }\n        if self.plugin:\n            to_hash[\"plugin\"] = self.plugin\n        return hashlib.sha256(json.dumps(to_hash).encode(\"utf-8\")).hexdigest()\n\n    @classmethod\n    def function(cls, function, name=None, description=None):\n        \"\"\"\n        Turn a Python function into a Tool object by:\n         - Extracting the function name\n         - Using the function docstring for the Tool description\n         - Building a Pydantic model for inputs by inspecting the function signature\n         - Building a Pydantic model for the return value by using the function's return annotation\n        \"\"\"\n        if not name and function.__name__ == \"<lambda>\":\n            raise ValueError(\n                \"Cannot create a Tool from a lambda function without providing name=\"\n            )\n\n        return cls(\n            name=name or function.__name__,\n            description=description or function.__doc__ or None,\n            input_schema=_get_arguments_input_schema(function, name),\n            implementation=function,\n        )\n\n\ndef _get_arguments_input_schema(function, name):\n    signature = inspect.signature(function)\n    type_hints = get_type_hints(function)\n    fields = {}\n    for param_name, param in signature.parameters.items():\n        if param_name == \"self\":\n            continue\n        # Determine the type annotation (default to string if missing)\n        annotated_type = type_hints.get(param_name, str)\n\n        # Handle default value if present; if there's no default, use '...'\n        if param.default is inspect.Parameter.empty:\n            fields[param_name] = (annotated_type, ...)\n        else:\n            fields[param_name] = (annotated_type, param.default)\n\n    return create_model(f\"{name}InputSchema\", **fields)\n\n\nclass Toolbox:\n    name: Optional[str] = None\n    instance_id: Optional[int] = None\n    _blocked = (\n        \"tools\",\n        \"add_tool\",\n        \"method_tools\",\n        \"__init_subclass__\",\n        \"prepare\",\n        \"prepare_async\",\n    )\n    _extra_tools: List[Tool] = []\n    _config: Dict[str, Any] = {}\n    _prepared: bool = False\n    _async_prepared: bool = False\n\n    def __init_subclass__(cls, **kwargs):\n        super().__init_subclass__(**kwargs)\n\n        original_init = cls.__init__\n\n        def wrapped_init(self, *args, **kwargs):\n            # Track args/kwargs passed to constructor in self._config\n            # so we can serialize them to a database entry later on\n            sig = inspect.signature(original_init)\n            bound = sig.bind(self, *args, **kwargs)\n            bound.apply_defaults()\n\n            self._config = {\n                name: value\n                for name, value in bound.arguments.items()\n                if name != \"self\"\n                and sig.parameters[name].kind\n                not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)\n            }\n            self._extra_tools = []\n\n            original_init(self, *args, **kwargs)\n\n        cls.__init__ = wrapped_init\n\n    @classmethod\n    def method_tools(cls) -> List[Tool]:\n        tools = []\n        for method_name in dir(cls):\n            if method_name.startswith(\"_\") or method_name in cls._blocked:\n                continue\n            method = getattr(cls, method_name)\n            if callable(method):\n                tool = Tool.function(\n                    method,\n                    name=\"{}_{}\".format(cls.__name__, method_name),\n                )\n                tools.append(tool)\n        return tools\n\n    def tools(self) -> Iterable[Tool]:\n        \"Returns an llm.Tool() for each class method, plus any extras registered with add_tool()\"\n        # method_tools() returns unbound methods, we need bound methods here:\n        for name in dir(self):\n            if name.startswith(\"_\") or name in self._blocked:\n                continue\n            attr = getattr(self, name)\n            if callable(attr):\n                tool = Tool.function(attr, name=f\"{self.__class__.__name__}_{name}\")\n                tool.plugin = getattr(self, \"plugin\", None)\n                yield tool\n        yield from self._extra_tools\n\n    def add_tool(\n        self, tool_or_function: Union[Tool, Callable[..., Any]], pass_self: bool = False\n    ):\n        \"Add a tool to this toolbox\"\n\n        def _upgrade(fn):\n            if pass_self:\n                return MethodType(fn, self)\n            return fn\n\n        if isinstance(tool_or_function, Tool):\n            self._extra_tools.append(tool_or_function)\n        elif callable(tool_or_function):\n            self._extra_tools.append(Tool.function(_upgrade(tool_or_function)))\n        else:\n            raise ValueError(\"Tool must be an instance of Tool or a callable function\")\n\n    def prepare(self):\n        \"\"\"\n        Over-ride this to perform setup (and .add_tool() calls) before the toolbox is used.\n        Implement a similar prepare_async() method for async setup.\n        \"\"\"\n        pass\n\n    async def prepare_async(self):\n        \"\"\"\n        Over-ride this to perform async setup (and .add_tool() calls) before the toolbox is used.\n        \"\"\"\n        pass\n\n\n@dataclass\nclass ToolCall:\n    name: str\n    arguments: dict\n    tool_call_id: Optional[str] = None\n\n\n@dataclass\nclass ToolResult:\n    name: str\n    output: str\n    attachments: List[Attachment] = field(default_factory=list)\n    tool_call_id: Optional[str] = None\n    instance: Optional[Toolbox] = None\n    exception: Optional[Exception] = None\n\n\n@dataclass\nclass ToolOutput:\n    \"Tool functions can return output with extra attachments\"\n\n    output: Optional[Union[str, dict, list, bool, int, float]] = None\n    attachments: List[Attachment] = field(default_factory=list)\n\n\nToolDef = Union[Tool, Toolbox, Callable[..., Any]]\nBeforeCallSync = Callable[[Optional[Tool], ToolCall], None]\nAfterCallSync = Callable[[Tool, ToolCall, ToolResult], None]\nBeforeCallAsync = Callable[[Optional[Tool], ToolCall], Union[None, Awaitable[None]]]\nAfterCallAsync = Callable[[Tool, ToolCall, ToolResult], Union[None, Awaitable[None]]]\n\n\nclass CancelToolCall(Exception):\n    pass\n\n\n@dataclass\nclass Prompt:\n    _prompt: Optional[str]\n    model: \"Model\"\n    fragments: Optional[List[Union[str, Fragment]]]\n    attachments: Optional[List[Attachment]]\n    _system: Optional[str]\n    system_fragments: Optional[List[Union[str, Fragment]]]\n    prompt_json: Optional[str]\n    schema: Optional[Union[Dict, type[BaseModel]]]\n    tools: List[Tool]\n    tool_results: List[ToolResult]\n    options: \"Options\"\n\n    def __init__(\n        self,\n        prompt,\n        model,\n        *,\n        fragments=None,\n        attachments=None,\n        system=None,\n        system_fragments=None,\n        prompt_json=None,\n        options=None,\n        schema=None,\n        tools=None,\n        tool_results=None,\n    ):\n        self._prompt = prompt\n        self.model = model\n        self.attachments = list(attachments or [])\n        self.fragments = fragments or []\n        self._system = system\n        self.system_fragments = system_fragments or []\n        self.prompt_json = prompt_json\n        if schema and not isinstance(schema, dict) and issubclass(schema, BaseModel):\n            schema = schema.model_json_schema()\n        self.schema = schema\n        self.tools = _wrap_tools(tools or [])\n        self.tool_results = tool_results or []\n        self.options = options or {}\n\n    @property\n    def prompt(self):\n        return \"\\n\".join(self.fragments + ([self._prompt] if self._prompt else []))\n\n    @property\n    def system(self):\n        bits = [\n            bit.strip()\n            for bit in (self.system_fragments + [self._system or \"\"])\n            if bit.strip()\n        ]\n        return \"\\n\\n\".join(bits)\n\n\ndef _wrap_tools(tools: List[ToolDef]) -> List[Tool]:\n    wrapped_tools = []\n    for tool in tools:\n        if isinstance(tool, Tool):\n            wrapped_tools.append(tool)\n        elif isinstance(tool, Toolbox):\n            wrapped_tools.extend(tool.tools())\n        elif callable(tool):\n            wrapped_tools.append(Tool.function(tool))\n        else:\n            raise ValueError(f\"Invalid tool: {tool}\")\n    return wrapped_tools\n\n\n@dataclass\nclass _BaseConversation:\n    model: \"_BaseModel\"\n    id: str = field(default_factory=lambda: str(monotonic_ulid()).lower())\n    name: Optional[str] = None\n    responses: List[\"_BaseResponse\"] = field(default_factory=list)\n    tools: Optional[List[ToolDef]] = None\n    chain_limit: Optional[int] = None\n\n    @classmethod\n    @abstractmethod\n    def from_row(cls, row: Any) -> \"_BaseConversation\":\n        raise NotImplementedError\n\n\n@dataclass\nclass Conversation(_BaseConversation):\n    before_call: Optional[BeforeCallSync] = None\n    after_call: Optional[AfterCallSync] = None\n\n    def prompt(\n        self,\n        prompt: Optional[str] = None,\n        *,\n        fragments: Optional[List[Union[str, Fragment]]] = None,\n        attachments: Optional[List[Attachment]] = None,\n        system: Optional[str] = None,\n        schema: Optional[Union[dict, type[BaseModel]]] = None,\n        tools: Optional[List[ToolDef]] = None,\n        tool_results: Optional[List[ToolResult]] = None,\n        system_fragments: Optional[List[Union[str, Fragment]]] = None,\n        stream: bool = True,\n        key: Optional[str] = None,\n        **options,\n    ) -> \"Response\":\n        return Response(\n            Prompt(\n                prompt,\n                model=self.model,\n                fragments=fragments,\n                attachments=attachments,\n                system=system,\n                schema=schema,\n                tools=tools or self.tools,\n                tool_results=tool_results,\n                system_fragments=system_fragments,\n                options=self.model.Options(**options),\n            ),\n            self.model,\n            stream,\n            conversation=self,\n            key=key,\n        )\n\n    def chain(\n        self,\n        prompt: Optional[str] = None,\n        *,\n        fragments: Optional[List[str]] = None,\n        attachments: Optional[List[Attachment]] = None,\n        system: Optional[str] = None,\n        system_fragments: Optional[List[str]] = None,\n        stream: bool = True,\n        schema: Optional[Union[dict, type[BaseModel]]] = None,\n        tools: Optional[List[ToolDef]] = None,\n        tool_results: Optional[List[ToolResult]] = None,\n        chain_limit: Optional[int] = None,\n        before_call: Optional[BeforeCallSync] = None,\n        after_call: Optional[AfterCallSync] = None,\n        key: Optional[str] = None,\n        options: Optional[dict] = None,\n    ) -> \"ChainResponse\":\n        self.model._validate_attachments(attachments)\n        return ChainResponse(\n            Prompt(\n                prompt,\n                fragments=fragments,\n                attachments=attachments,\n                system=system,\n                schema=schema,\n                tools=tools or self.tools,\n                tool_results=tool_results,\n                system_fragments=system_fragments,\n                model=self.model,\n                options=self.model.Options(**(options or {})),\n            ),\n            model=self.model,\n            stream=stream,\n            conversation=self,\n            key=key,\n            before_call=before_call or self.before_call,\n            after_call=after_call or self.after_call,\n            chain_limit=chain_limit if chain_limit is not None else self.chain_limit,\n        )\n\n    @classmethod\n    def from_row(cls, row):\n        from llm import get_model\n\n        return cls(\n            model=get_model(row[\"model\"]),\n            id=row[\"id\"],\n            name=row[\"name\"],\n        )\n\n    def __repr__(self):\n        count = len(self.responses)\n        s = \"s\" if count == 1 else \"\"\n        return f\"<{self.__class__.__name__}: {self.id} - {count} response{s}\"\n\n\n@dataclass\nclass AsyncConversation(_BaseConversation):\n    before_call: Optional[BeforeCallAsync] = None\n    after_call: Optional[AfterCallAsync] = None\n\n    def chain(\n        self,\n        prompt: Optional[str] = None,\n        *,\n        fragments: Optional[List[str]] = None,\n        attachments: Optional[List[Attachment]] = None,\n        system: Optional[str] = None,\n        system_fragments: Optional[List[str]] = None,\n        stream: bool = True,\n        schema: Optional[Union[dict, type[BaseModel]]] = None,\n        tools: Optional[List[ToolDef]] = None,\n        tool_results: Optional[List[ToolResult]] = None,\n        chain_limit: Optional[int] = None,\n        before_call: Optional[BeforeCallAsync] = None,\n        after_call: Optional[AfterCallAsync] = None,\n        key: Optional[str] = None,\n        options: Optional[dict] = None,\n    ) -> \"AsyncChainResponse\":\n        self.model._validate_attachments(attachments)\n        return AsyncChainResponse(\n            Prompt(\n                prompt,\n                fragments=fragments,\n                attachments=attachments,\n                system=system,\n                schema=schema,\n                tools=tools or self.tools,\n                tool_results=tool_results,\n                system_fragments=system_fragments,\n                model=self.model,\n                options=self.model.Options(**(options or {})),\n            ),\n            model=self.model,\n            stream=stream,\n            conversation=self,\n            key=key,\n            before_call=before_call or self.before_call,\n            after_call=after_call or self.after_call,\n            chain_limit=chain_limit if chain_limit is not None else self.chain_limit,\n        )\n\n    def prompt(\n        self,\n        prompt: Optional[str] = None,\n        *,\n        fragments: Optional[List[str]] = None,\n        attachments: Optional[List[Attachment]] = None,\n        system: Optional[str] = None,\n        schema: Optional[Union[dict, type[BaseModel]]] = None,\n        tools: Optional[List[ToolDef]] = None,\n        tool_results: Optional[List[ToolResult]] = None,\n        system_fragments: Optional[List[str]] = None,\n        stream: bool = True,\n        key: Optional[str] = None,\n        **options,\n    ) -> \"AsyncResponse\":\n        return AsyncResponse(\n            Prompt(\n                prompt,\n                model=self.model,\n                fragments=fragments,\n                attachments=attachments,\n                system=system,\n                schema=schema,\n                tools=tools,\n                tool_results=tool_results,\n                system_fragments=system_fragments,\n                options=self.model.Options(**options),\n            ),\n            self.model,\n            stream,\n            conversation=self,\n            key=key,\n        )\n\n    def to_sync_conversation(self):\n        return Conversation(\n            model=self.model,\n            id=self.id,\n            name=self.name,\n            responses=[],  # Because we only use this in logging\n            tools=self.tools,\n            chain_limit=self.chain_limit,\n        )\n\n    @classmethod\n    def from_row(cls, row):\n        from llm import get_async_model\n\n        return cls(\n            model=get_async_model(row[\"model\"]),\n            id=row[\"id\"],\n            name=row[\"name\"],\n        )\n\n    def __repr__(self):\n        count = len(self.responses)\n        s = \"s\" if count == 1 else \"\"\n        return f\"<{self.__class__.__name__}: {self.id} - {count} response{s}\"\n\n\nFRAGMENT_SQL = \"\"\"\nselect\n    'prompt' as fragment_type,\n    fragments.content,\n    pf.\"order\" as ord\nfrom prompt_fragments pf\njoin fragments on pf.fragment_id = fragments.id\nwhere pf.response_id = :response_id\nunion all\nselect\n    'system' as fragment_type,\n    fragments.content,\n    sf.\"order\" as ord\nfrom system_fragments sf\njoin fragments on sf.fragment_id = fragments.id\nwhere sf.response_id = :response_id\norder by fragment_type desc, ord asc;\n\"\"\"\n\n\nclass _BaseResponse:\n    \"\"\"Base response class shared between sync and async responses\"\"\"\n\n    id: str\n    prompt: \"Prompt\"\n    stream: bool\n    resolved_model: Optional[str] = None\n    conversation: Optional[\"_BaseConversation\"] = None\n    _key: Optional[str] = None\n    _tool_calls: List[ToolCall] = []\n\n    def __init__(\n        self,\n        prompt: Prompt,\n        model: \"_BaseModel\",\n        stream: bool,\n        conversation: Optional[_BaseConversation] = None,\n        key: Optional[str] = None,\n    ):\n        self.id = str(monotonic_ulid()).lower()\n        self.prompt = prompt\n        self._prompt_json = None\n        self.model = model\n        self.stream = stream\n        self._key = key\n        self._chunks: List[str] = []\n        self._done = False\n        self._tool_calls: List[ToolCall] = []\n        self.response_json: Optional[Dict[str, Any]] = None\n        self.conversation = conversation\n        self.attachments: List[Attachment] = []\n        self._start: Optional[float] = None\n        self._end: Optional[float] = None\n        self._start_utcnow: Optional[datetime.datetime] = None\n        self.input_tokens: Optional[int] = None\n        self.output_tokens: Optional[int] = None\n        self.token_details: Optional[dict] = None\n        self.done_callbacks: List[Callable] = []\n\n        if self.prompt.schema and not self.model.supports_schema:\n            raise ValueError(f\"{self.model} does not support schemas\")\n\n        if self.prompt.tools and not self.model.supports_tools:\n            raise ValueError(f\"{self.model} does not support tools\")\n\n    def add_tool_call(self, tool_call: ToolCall):\n        self._tool_calls.append(tool_call)\n\n    def set_usage(\n        self,\n        *,\n        input: Optional[int] = None,\n        output: Optional[int] = None,\n        details: Optional[dict] = None,\n    ):\n        self.input_tokens = input\n        self.output_tokens = output\n        self.token_details = details\n\n    def set_resolved_model(self, model_id: str):\n        self.resolved_model = model_id\n\n    @classmethod\n    def from_row(cls, db, row, _async=False):\n        from llm import get_model, get_async_model\n\n        if _async:\n            model = get_async_model(row[\"model\"])\n        else:\n            model = get_model(row[\"model\"])\n\n        # Schema\n        schema = None\n        if row[\"schema_id\"]:\n            schema = json.loads(db[\"schemas\"].get(row[\"schema_id\"])[\"content\"])\n\n        # Tool definitions and results for prompt\n        tools = [\n            Tool(\n                name=tool_row[\"name\"],\n                description=tool_row[\"description\"],\n                input_schema=json.loads(tool_row[\"input_schema\"]),\n                # In this case we don't have a reference to the actual Python code\n                # but that's OK, we should not need it for prompts deserialized from DB\n                implementation=None,\n                plugin=tool_row[\"plugin\"],\n            )\n            for tool_row in db.query(\n                \"\"\"\n                select tools.* from tools\n                join tool_responses on tools.id = tool_responses.tool_id\n                where tool_responses.response_id = ?\n            \"\"\",\n                [row[\"id\"]],\n            )\n        ]\n        tool_results = [\n            ToolResult(\n                name=tool_results_row[\"name\"],\n                output=tool_results_row[\"output\"],\n                tool_call_id=tool_results_row[\"tool_call_id\"],\n            )\n            for tool_results_row in db.query(\n                \"\"\"\n                select * from tool_results\n                where response_id = ?\n            \"\"\",\n                [row[\"id\"]],\n            )\n        ]\n\n        all_fragments = list(db.query(FRAGMENT_SQL, {\"response_id\": row[\"id\"]}))\n        fragments = [\n            row[\"content\"] for row in all_fragments if row[\"fragment_type\"] == \"prompt\"\n        ]\n        system_fragments = [\n            row[\"content\"] for row in all_fragments if row[\"fragment_type\"] == \"system\"\n        ]\n        response = cls(\n            model=model,\n            prompt=Prompt(\n                prompt=row[\"prompt\"],\n                model=model,\n                fragments=fragments,\n                attachments=[],\n                system=row[\"system\"],\n                schema=schema,\n                tools=tools,\n                tool_results=tool_results,\n                system_fragments=system_fragments,\n                options=model.Options(**json.loads(row[\"options_json\"])),\n            ),\n            stream=False,\n        )\n        prompt_json = json.loads(row[\"prompt_json\"] or \"null\")\n        response.id = row[\"id\"]\n        response._prompt_json = prompt_json\n        response.response_json = json.loads(row[\"response_json\"] or \"null\")\n        response._done = True\n        response._chunks = [row[\"response\"]]\n        # Attachments\n        response.attachments = [\n            Attachment.from_row(attachment_row)\n            for attachment_row in db.query(\n                \"\"\"\n                select attachments.* from attachments\n                join prompt_attachments on attachments.id = prompt_attachments.attachment_id\n                where prompt_attachments.response_id = ?\n                order by prompt_attachments.\"order\"\n            \"\"\",\n                [row[\"id\"]],\n            )\n        ]\n        # Tool calls\n        response._tool_calls = [\n            ToolCall(\n                name=tool_row[\"name\"],\n                arguments=json.loads(tool_row[\"arguments\"]),\n                tool_call_id=tool_row[\"tool_call_id\"],\n            )\n            for tool_row in db.query(\n                \"\"\"\n                select * from tool_calls\n                where response_id = ?\n                order by tool_call_id\n            \"\"\",\n                [row[\"id\"]],\n            )\n        ]\n\n        return response\n\n    def token_usage(self) -> str:\n        return token_usage_string(\n            self.input_tokens, self.output_tokens, self.token_details\n        )\n\n    def log_to_db(self, db):\n        conversation = self.conversation\n        if not conversation:\n            conversation = Conversation(model=self.model)\n        db[\"conversations\"].insert(\n            {\n                \"id\": conversation.id,\n                \"name\": _conversation_name(\n                    self.prompt.prompt or self.prompt.system or \"\"\n                ),\n                \"model\": conversation.model.model_id,\n            },\n            ignore=True,\n        )\n        schema_id = None\n        if self.prompt.schema:\n            schema_id, schema_json = make_schema_id(self.prompt.schema)\n            db[\"schemas\"].insert({\"id\": schema_id, \"content\": schema_json}, ignore=True)\n\n        response_id = self.id\n        replacements = {}\n        # Include replacements from previous responses\n        for previous_response in conversation.responses[:-1]:\n            for fragment in (previous_response.prompt.fragments or []) + (\n                previous_response.prompt.system_fragments or []\n            ):\n                fragment_id = ensure_fragment(db, fragment)\n                replacements[f\"f:{fragment_id}\"] = fragment\n                replacements[f\"r:{previous_response.id}\"] = (\n                    previous_response.text_or_raise()\n                )\n\n        for i, fragment in enumerate(self.prompt.fragments):\n            fragment_id = ensure_fragment(db, fragment)\n            replacements[f\"f{fragment_id}\"] = fragment\n            db[\"prompt_fragments\"].insert(\n                {\n                    \"response_id\": response_id,\n                    \"fragment_id\": fragment_id,\n                    \"order\": i,\n                },\n            )\n        for i, fragment in enumerate(self.prompt.system_fragments):\n            fragment_id = ensure_fragment(db, fragment)\n            replacements[f\"f{fragment_id}\"] = fragment\n            db[\"system_fragments\"].insert(\n                {\n                    \"response_id\": response_id,\n                    \"fragment_id\": fragment_id,\n                    \"order\": i,\n                },\n            )\n\n        response_text = self.text_or_raise()\n        replacements[f\"r:{response_id}\"] = response_text\n        json_data = self.json()\n\n        response = {\n            \"id\": response_id,\n            \"model\": self.model.model_id,\n            \"prompt\": self.prompt._prompt,\n            \"system\": self.prompt._system,\n            \"prompt_json\": condense_json(self._prompt_json, replacements),\n            \"options_json\": {\n                key: value\n                for key, value in dict(self.prompt.options).items()\n                if value is not None\n            },\n            \"response\": response_text,\n            \"response_json\": condense_json(json_data, replacements),\n            \"conversation_id\": conversation.id,\n            \"duration_ms\": self.duration_ms(),\n            \"datetime_utc\": self.datetime_utc(),\n            \"input_tokens\": self.input_tokens,\n            \"output_tokens\": self.output_tokens,\n            \"token_details\": (\n                json.dumps(self.token_details) if self.token_details else None\n            ),\n            \"schema_id\": schema_id,\n            \"resolved_model\": self.resolved_model,\n        }\n        db[\"responses\"].insert(response)\n\n        # Persist any attachments - loop through with index\n        for index, attachment in enumerate(self.prompt.attachments):\n            attachment_id = attachment.id()\n            db[\"attachments\"].insert(\n                {\n                    \"id\": attachment_id,\n                    \"type\": attachment.resolve_type(),\n                    \"path\": attachment.path,\n                    \"url\": attachment.url,\n                    \"content\": attachment.content,\n                },\n                replace=True,\n            )\n            db[\"prompt_attachments\"].insert(\n                {\n                    \"response_id\": response_id,\n                    \"attachment_id\": attachment_id,\n                    \"order\": index,\n                },\n            )\n\n        # Persist any tools, tool calls and tool results\n        tool_ids_by_name = {}\n        for tool in self.prompt.tools:\n            tool_id = ensure_tool(db, tool)\n            tool_ids_by_name[tool.name] = tool_id\n            db[\"tool_responses\"].insert(\n                {\n                    \"tool_id\": tool_id,\n                    \"response_id\": response_id,\n                }\n            )\n        for tool_call in self.tool_calls():  # TODO Should  be _or_raise()\n            db[\"tool_calls\"].insert(\n                {\n                    \"response_id\": response_id,\n                    \"tool_id\": tool_ids_by_name.get(tool_call.name) or None,\n                    \"name\": tool_call.name,\n                    \"arguments\": json.dumps(tool_call.arguments),\n                    \"tool_call_id\": tool_call.tool_call_id,\n                }\n            )\n        for tool_result in self.prompt.tool_results:\n            instance_id = None\n            if tool_result.instance:\n                try:\n                    if not tool_result.instance.instance_id:\n                        tool_result.instance.instance_id = (\n                            db[\"tool_instances\"]\n                            .insert(\n                                {\n                                    \"plugin\": tool.plugin,\n                                    \"name\": tool.name.split(\"_\")[0],\n                                    \"arguments\": json.dumps(\n                                        tool_result.instance._config\n                                    ),\n                                }\n                            )\n                            .last_pk\n                        )\n                    instance_id = tool_result.instance.instance_id\n                except AttributeError:\n                    pass\n            tool_result_id = (\n                db[\"tool_results\"]\n                .insert(\n                    {\n                        \"response_id\": response_id,\n                        \"tool_id\": tool_ids_by_name.get(tool_result.name) or None,\n                        \"name\": tool_result.name,\n                        \"output\": tool_result.output,\n                        \"tool_call_id\": tool_result.tool_call_id,\n                        \"instance_id\": instance_id,\n                        \"exception\": (\n                            (\n                                \"{}: {}\".format(\n                                    tool_result.exception.__class__.__name__,\n                                    str(tool_result.exception),\n                                )\n                            )\n                            if tool_result.exception\n                            else None\n                        ),\n                    }\n                )\n                .last_pk\n            )\n            # Persist attachments for tool results\n            for index, attachment in enumerate(tool_result.attachments):\n                attachment_id = attachment.id()\n                db[\"attachments\"].insert(\n                    {\n                        \"id\": attachment_id,\n                        \"type\": attachment.resolve_type(),\n                        \"path\": attachment.path,\n                        \"url\": attachment.url,\n                        \"content\": attachment.content,\n                    },\n                    replace=True,\n                )\n                db[\"tool_results_attachments\"].insert(\n                    {\n                        \"tool_result_id\": tool_result_id,\n                        \"attachment_id\": attachment_id,\n                        \"order\": index,\n                    },\n                )\n\n\nclass Response(_BaseResponse):\n    model: \"Model\"\n    conversation: Optional[\"Conversation\"] = None\n\n    def on_done(self, callback):\n        if not self._done:\n            self.done_callbacks.append(callback)\n        else:\n            callback(self)\n\n    def _on_done(self):\n        for callback in self.done_callbacks:\n            callback(self)\n\n    def __str__(self) -> str:\n        return self.text()\n\n    def _force(self):\n        if not self._done:\n            list(self)\n\n    def text(self) -> str:\n        self._force()\n        return \"\".join(self._chunks)\n\n    def text_or_raise(self) -> str:\n        return self.text()\n\n    def execute_tool_calls(\n        self,\n        *,\n        before_call: Optional[BeforeCallSync] = None,\n        after_call: Optional[AfterCallSync] = None,\n    ) -> List[ToolResult]:\n        tool_results = []\n        tools_by_name = {tool.name: tool for tool in self.prompt.tools}\n\n        # Run prepare() on all Toolbox instances that need it\n        instances_to_prepare: list[Toolbox] = []\n        for tool_to_prep in tools_by_name.values():\n            inst = _get_instance(tool_to_prep.implementation)\n            if isinstance(inst, Toolbox) and not getattr(inst, \"_prepared\", False):\n                instances_to_prepare.append(inst)\n\n        for inst in instances_to_prepare:\n            inst.prepare()\n            inst._prepared = True\n\n        for tool_call in self.tool_calls():\n            tool: Optional[Tool] = tools_by_name.get(tool_call.name)\n            # Tool could be None if the tool was not found in the prompt tools,\n            # but we still call the before_call method:\n            if before_call:\n                try:\n                    cb_result = before_call(tool, tool_call)\n                    if inspect.isawaitable(cb_result):\n                        raise TypeError(\n                            \"Asynchronous 'before_call' callback provided to a synchronous tool execution context. \"\n                            \"Please use an async chain/response or a synchronous callback.\"\n                        )\n                except CancelToolCall as ex:\n                    tool_results.append(\n                        ToolResult(\n                            name=tool_call.name,\n                            output=\"Cancelled: \" + str(ex),\n                            tool_call_id=tool_call.tool_call_id,\n                            exception=ex,\n                        )\n                    )\n                    continue\n\n            if tool is None:\n                msg = 'tool \"{}\" does not exist'.format(tool_call.name)\n                tool_results.append(\n                    ToolResult(\n                        name=tool_call.name,\n                        output=\"Error: \" + msg,\n                        tool_call_id=tool_call.tool_call_id,\n                        exception=KeyError(msg),\n                    )\n                )\n                continue\n\n            if not tool.implementation:\n                raise ValueError(\n                    \"No implementation available for tool: {}\".format(tool_call.name)\n                )\n\n            attachments = []\n            exception = None\n\n            try:\n                if inspect.iscoroutinefunction(tool.implementation):\n                    result = asyncio.run(tool.implementation(**tool_call.arguments))\n                else:\n                    result = tool.implementation(**tool_call.arguments)\n\n                if isinstance(result, ToolOutput):\n                    attachments = result.attachments\n                    result = result.output\n\n                if not isinstance(result, str):\n                    result = json.dumps(result, default=repr)\n            except Exception as ex:\n                result = f\"Error: {ex}\"\n                exception = ex\n\n            tool_result_obj = ToolResult(\n                name=tool_call.name,\n                output=result,\n                attachments=attachments,\n                tool_call_id=tool_call.tool_call_id,\n                instance=_get_instance(tool.implementation),\n                exception=exception,\n            )\n\n            if after_call:\n                cb_result = after_call(tool, tool_call, tool_result_obj)\n                if inspect.isawaitable(cb_result):\n                    raise TypeError(\n                        \"Asynchronous 'after_call' callback provided to a synchronous tool execution context. \"\n                        \"Please use an async chain/response or a synchronous callback.\"\n                    )\n            tool_results.append(tool_result_obj)\n        return tool_results\n\n    def tool_calls(self) -> List[ToolCall]:\n        self._force()\n        return self._tool_calls\n\n    def tool_calls_or_raise(self) -> List[ToolCall]:\n        return self.tool_calls()\n\n    def json(self) -> Optional[Dict[str, Any]]:\n        self._force()\n        return self.response_json\n\n    def duration_ms(self) -> int:\n        self._force()\n        return int(((self._end or 0) - (self._start or 0)) * 1000)\n\n    def datetime_utc(self) -> str:\n        self._force()\n        return self._start_utcnow.isoformat() if self._start_utcnow else \"\"\n\n    def usage(self) -> Usage:\n        self._force()\n        return Usage(\n            input=self.input_tokens,\n            output=self.output_tokens,\n            details=self.token_details,\n        )\n\n    def __iter__(self) -> Iterator[str]:\n        self._start = time.monotonic()\n        self._start_utcnow = datetime.datetime.now(datetime.timezone.utc)\n        if self._done:\n            yield from self._chunks\n            return\n\n        if isinstance(self.model, Model):\n            for chunk in self.model.execute(\n                self.prompt,\n                stream=self.stream,\n                response=self,\n                conversation=self.conversation,\n            ):\n                assert chunk is not None\n                yield chunk\n                self._chunks.append(chunk)\n        elif isinstance(self.model, KeyModel):\n            for chunk in self.model.execute(\n                self.prompt,\n                stream=self.stream,\n                response=self,\n                conversation=self.conversation,\n                key=self.model.get_key(self._key),\n            ):\n                assert chunk is not None\n                yield chunk\n                self._chunks.append(chunk)\n        else:\n            raise Exception(\"self.model must be a Model or KeyModel\")\n\n        if self.conversation:\n            self.conversation.responses.append(self)\n        self._end = time.monotonic()\n        self._done = True\n        self._on_done()\n\n    def __repr__(self):\n        text = \"... not yet done ...\"\n        if self._done:\n            text = \"\".join(self._chunks)\n        return \"<Response prompt='{}' text='{}'>\".format(self.prompt.prompt, text)\n\n\nclass AsyncResponse(_BaseResponse):\n    model: \"AsyncModel\"\n    conversation: Optional[\"AsyncConversation\"] = None\n\n    @classmethod\n    def from_row(cls, db, row, _async=False):\n        return super().from_row(db, row, _async=True)\n\n    async def on_done(self, callback):\n        if not self._done:\n            self.done_callbacks.append(callback)\n        else:\n            if callable(callback):\n                # Ensure we handle both sync and async callbacks correctly\n                processed_callback = callback(self)\n                if inspect.isawaitable(processed_callback):\n                    await processed_callback\n            elif inspect.isawaitable(callback):\n                await callback\n\n    async def _on_done(self):\n        for callback_func in self.done_callbacks:\n            if callable(callback_func):\n                processed_callback = callback_func(self)\n                if inspect.isawaitable(processed_callback):\n                    await processed_callback\n            elif inspect.isawaitable(callback_func):\n                await callback_func\n\n    async def execute_tool_calls(\n        self,\n        *,\n        before_call: Optional[BeforeCallAsync] = None,\n        after_call: Optional[AfterCallAsync] = None,\n    ) -> List[ToolResult]:\n        tool_calls_list = await self.tool_calls()\n        tools_by_name = {tool.name: tool for tool in self.prompt.tools}\n\n        # Run async prepare_async() on all Toolbox instances that need it\n        instances_to_prepare: list[Toolbox] = []\n        for tool_to_prep in tools_by_name.values():\n            inst = _get_instance(tool_to_prep.implementation)\n            if isinstance(inst, Toolbox) and not getattr(\n                inst, \"_async_prepared\", False\n            ):\n                instances_to_prepare.append(inst)\n\n        for inst in instances_to_prepare:\n            await inst.prepare_async()\n            inst._async_prepared = True\n\n        indexed_results: List[tuple[int, ToolResult]] = []\n        async_tasks: List[asyncio.Task] = []\n\n        for idx, tc in enumerate(tool_calls_list):\n            tool: Optional[Tool] = tools_by_name.get(tc.name)\n            exception: Optional[Exception] = None\n\n            if tool is None:\n                output = f'Error: tool \"{tc.name}\" does not exist'\n                exception = KeyError(tc.name)\n            elif not tool.implementation:\n                output = f'Error: tool \"{tc.name}\" has no implementation'\n                exception = KeyError(tc.name)\n            elif inspect.iscoroutinefunction(tool.implementation):\n\n                async def run_async(tc=tc, tool=tool, idx=idx):\n                    # before_call inside the task\n                    if before_call:\n                        try:\n                            cb = before_call(tool, tc)\n                            if inspect.isawaitable(cb):\n                                await cb\n                        except CancelToolCall as ex:\n                            return idx, ToolResult(\n                                name=tc.name,\n                                output=\"Cancelled: \" + str(ex),\n                                tool_call_id=tc.tool_call_id,\n                                exception=ex,\n                            )\n\n                    exception = None\n                    attachments = []\n\n                    try:\n                        result = await tool.implementation(**tc.arguments)\n                        if isinstance(result, ToolOutput):\n                            attachments.extend(result.attachments)\n                            result = result.output\n                        output = (\n                            result\n                            if isinstance(result, str)\n                            else json.dumps(result, default=repr)\n                        )\n                    except Exception as ex:\n                        output = f\"Error: {ex}\"\n                        exception = ex\n\n                    tr = ToolResult(\n                        name=tc.name,\n                        output=output,\n                        attachments=attachments,\n                        tool_call_id=tc.tool_call_id,\n                        instance=_get_instance(tool.implementation),\n                        exception=exception,\n                    )\n\n                    # after_call inside the task\n                    if tool is not None and after_call:\n                        cb2 = after_call(tool, tc, tr)\n                        if inspect.isawaitable(cb2):\n                            await cb2\n\n                    return idx, tr\n\n                async_tasks.append(asyncio.create_task(run_async()))\n\n            else:\n                # Sync implementation: do hooks and call inline\n                if before_call:\n                    try:\n                        cb = before_call(tool, tc)\n                        if inspect.isawaitable(cb):\n                            await cb\n                    except CancelToolCall as ex:\n                        indexed_results.append(\n                            (\n                                idx,\n                                ToolResult(\n                                    name=tc.name,\n                                    output=\"Cancelled: \" + str(ex),\n                                    tool_call_id=tc.tool_call_id,\n                                    exception=ex,\n                                ),\n                            )\n                        )\n                        continue\n\n                exception = None\n                attachments = []\n\n                if tool is None:\n                    output = f'Error: tool \"{tc.name}\" does not exist'\n                    exception = KeyError(tc.name)\n                else:\n                    try:\n                        res = tool.implementation(**tc.arguments)\n                        if inspect.isawaitable(res):\n                            res = await res\n                        if isinstance(res, ToolOutput):\n                            attachments.extend(res.attachments)\n                            res = res.output\n                        output = (\n                            res\n                            if isinstance(res, str)\n                            else json.dumps(res, default=repr)\n                        )\n                    except Exception as ex:\n                        output = f\"Error: {ex}\"\n                        exception = ex\n\n                    tr = ToolResult(\n                        name=tc.name,\n                        output=output,\n                        attachments=attachments,\n                        tool_call_id=tc.tool_call_id,\n                        instance=_get_instance(tool.implementation),\n                        exception=exception,\n                    )\n\n                    if tool is not None and after_call:\n                        cb2 = after_call(tool, tc, tr)\n                        if inspect.isawaitable(cb2):\n                            await cb2\n\n                    indexed_results.append((idx, tr))\n\n        # Await all async tasks in parallel\n        if async_tasks:\n            indexed_results.extend(await asyncio.gather(*async_tasks))\n\n        # Reorder by original index\n        indexed_results.sort(key=lambda x: x[0])\n        return [tr for _, tr in indexed_results]\n\n    def __aiter__(self):\n        self._start = time.monotonic()\n        self._start_utcnow = datetime.datetime.now(datetime.timezone.utc)\n        if self._done:\n            self._iter_chunks = list(self._chunks)  # Make a copy for iteration\n        return self\n\n    async def __anext__(self) -> str:\n        if self._done:\n            if hasattr(self, \"_iter_chunks\") and self._iter_chunks:\n                return self._iter_chunks.pop(0)\n            raise StopAsyncIteration\n\n        if not hasattr(self, \"_generator\"):\n            if isinstance(self.model, AsyncModel):\n                self._generator = self.model.execute(\n                    self.prompt,\n                    stream=self.stream,\n                    response=self,\n                    conversation=self.conversation,\n                )\n            elif isinstance(self.model, AsyncKeyModel):\n                self._generator = self.model.execute(\n                    self.prompt,\n                    stream=self.stream,\n                    response=self,\n                    conversation=self.conversation,\n                    key=self.model.get_key(self._key),\n                )\n            else:\n                raise ValueError(\"self.model must be an AsyncModel or AsyncKeyModel\")\n\n        try:\n            chunk = await self._generator.__anext__()\n            assert chunk is not None\n            self._chunks.append(chunk)\n            return chunk\n        except StopAsyncIteration:\n            if self.conversation:\n                self.conversation.responses.append(self)\n            self._end = time.monotonic()\n            self._done = True\n            if hasattr(self, \"_generator\"):\n                del self._generator\n            await self._on_done()\n            raise\n\n    async def _force(self):\n        if not self._done:\n            temp_chunks = []\n            async for chunk in self:\n                temp_chunks.append(chunk)\n            # This should populate self._chunks\n        return self\n\n    def text_or_raise(self) -> str:\n        if not self._done:\n            raise ValueError(\"Response not yet awaited\")\n        return \"\".join(self._chunks)\n\n    async def text(self) -> str:\n        await self._force()\n        return \"\".join(self._chunks)\n\n    async def tool_calls(self) -> List[ToolCall]:\n        await self._force()\n        return self._tool_calls\n\n    def tool_calls_or_raise(self) -> List[ToolCall]:\n        if not self._done:\n            raise ValueError(\"Response not yet awaited\")\n        return self._tool_calls\n\n    async def json(self) -> Optional[Dict[str, Any]]:\n        await self._force()\n        return self.response_json\n\n    async def duration_ms(self) -> int:\n        await self._force()\n        return int(((self._end or 0) - (self._start or 0)) * 1000)\n\n    async def datetime_utc(self) -> str:\n        await self._force()\n        return self._start_utcnow.isoformat() if self._start_utcnow else \"\"\n\n    async def usage(self) -> Usage:\n        await self._force()\n        return Usage(\n            input=self.input_tokens,\n            output=self.output_tokens,\n            details=self.token_details,\n        )\n\n    def __await__(self):\n        return self._force().__await__()\n\n    async def to_sync_response(self) -> Response:\n        await self._force()\n        # This conversion might be tricky if the model is AsyncModel,\n        # as Response expects a sync Model. For simplicity, we'll assume\n        # the primary use case is data transfer after completion.\n        # The model type on the new Response might need careful handling\n        # if it's intended for further execution.\n        # For now, let's assume self.model can be cast or is compatible.\n        sync_model = self.model\n        if not isinstance(self.model, (Model, KeyModel)):\n            # This is a placeholder. A proper conversion or shared base might be needed\n            # if the sync_response needs to be fully functional with its model.\n            # For now, we pass the async model, which might limit what sync_response can do.\n            pass\n\n        response = Response(\n            self.prompt,\n            sync_model,  # This might need adjustment based on how Model/AsyncModel relate\n            self.stream,\n            # conversation type needs to be compatible too.\n            conversation=(\n                self.conversation.to_sync_conversation() if self.conversation else None\n            ),\n        )\n        response.id = self.id\n        response._chunks = list(self._chunks)  # Copy chunks\n        response._done = self._done\n        response._end = self._end\n        response._start = self._start\n        response._start_utcnow = self._start_utcnow\n        response.input_tokens = self.input_tokens\n        response.output_tokens = self.output_tokens\n        response.token_details = self.token_details\n        response._prompt_json = self._prompt_json\n        response.response_json = self.response_json\n        response._tool_calls = list(self._tool_calls)\n        response.attachments = list(self.attachments)\n        response.resolved_model = self.resolved_model\n        return response\n\n    @classmethod\n    def fake(\n        cls,\n        model: \"AsyncModel\",\n        prompt: str,\n        *attachments: List[Attachment],\n        system: str,\n        response: str,\n    ):\n        \"Utility method to help with writing tests\"\n        response_obj = cls(\n            model=model,\n            prompt=Prompt(\n                prompt,\n                model=model,\n                attachments=attachments,\n                system=system,\n            ),\n            stream=False,\n        )\n        response_obj._done = True\n        response_obj._chunks = [response]\n        return response_obj\n\n    def __repr__(self):\n        text = \"... not yet awaited ...\"\n        if self._done:\n            text = \"\".join(self._chunks)\n        return \"<AsyncResponse prompt='{}' text='{}'>\".format(self.prompt.prompt, text)\n\n\nclass _BaseChainResponse:\n    prompt: \"Prompt\"\n    stream: bool\n    conversation: Optional[\"_BaseConversation\"] = None\n    _key: Optional[str] = None\n\n    def __init__(\n        self,\n        prompt: Prompt,\n        model: \"_BaseModel\",\n        stream: bool,\n        conversation: _BaseConversation,\n        key: Optional[str] = None,\n        chain_limit: Optional[int] = 10,\n        before_call: Optional[Union[BeforeCallSync, BeforeCallAsync]] = None,\n        after_call: Optional[Union[AfterCallSync, AfterCallAsync]] = None,\n    ):\n        self.prompt = prompt\n        self.model = model\n        self.stream = stream\n        self._key = key\n        self._responses: List[Any] = []\n        self.conversation = conversation\n        self.chain_limit = chain_limit\n        self.before_call = before_call\n        self.after_call = after_call\n\n    def log_to_db(self, db):\n        for response in self._responses:\n            if isinstance(response, AsyncResponse):\n                sync_response = asyncio.run(response.to_sync_response())\n            elif isinstance(response, Response):\n                sync_response = response\n            else:\n                assert False, \"Should have been a Response or AsyncResponse\"\n            sync_response.log_to_db(db)\n\n\nclass ChainResponse(_BaseChainResponse):\n    _responses: List[\"Response\"]\n    before_call: Optional[BeforeCallSync] = None\n    after_call: Optional[AfterCallSync] = None\n\n    def responses(self) -> Iterator[Response]:\n        prompt = self.prompt\n        count = 0\n        current_response: Optional[Response] = Response(\n            prompt,\n            self.model,\n            self.stream,\n            key=self._key,\n            conversation=self.conversation,\n        )\n        while current_response:\n            count += 1\n            yield current_response\n            self._responses.append(current_response)\n            if self.chain_limit and count >= self.chain_limit:\n                raise ValueError(f\"Chain limit of {self.chain_limit} exceeded.\")\n\n            # This could raise llm.CancelToolCall:\n            tool_results = current_response.execute_tool_calls(\n                before_call=self.before_call, after_call=self.after_call\n            )\n            attachments = []\n            for tool_result in tool_results:\n                attachments.extend(tool_result.attachments)\n            if tool_results:\n                current_response = Response(\n                    Prompt(\n                        \"\",  # Next prompt is empty, tools drive it\n                        self.model,\n                        tools=current_response.prompt.tools,\n                        tool_results=tool_results,\n                        options=self.prompt.options,\n                        attachments=attachments,\n                    ),\n                    self.model,\n                    stream=self.stream,\n                    key=self._key,\n                    conversation=self.conversation,\n                )\n            else:\n                current_response = None\n                break\n\n    def __iter__(self) -> Iterator[str]:\n        for response_item in self.responses():\n            yield from response_item\n\n    def text(self) -> str:\n        return \"\".join(self)\n\n\nclass AsyncChainResponse(_BaseChainResponse):\n    _responses: List[\"AsyncResponse\"]\n    before_call: Optional[BeforeCallAsync] = None\n    after_call: Optional[AfterCallAsync] = None\n\n    async def responses(self) -> AsyncIterator[AsyncResponse]:\n        prompt = self.prompt\n        count = 0\n        current_response: Optional[AsyncResponse] = AsyncResponse(\n            prompt,\n            self.model,\n            self.stream,\n            key=self._key,\n            conversation=self.conversation,\n        )\n        while current_response:\n            count += 1\n            yield current_response\n            self._responses.append(current_response)\n\n            if self.chain_limit and count >= self.chain_limit:\n                raise ValueError(f\"Chain limit of {self.chain_limit} exceeded.\")\n\n            # This could raise llm.CancelToolCall:\n            tool_results = await current_response.execute_tool_calls(\n                before_call=self.before_call, after_call=self.after_call\n            )\n            if tool_results:\n                attachments = []\n                for tool_result in tool_results:\n                    attachments.extend(tool_result.attachments)\n                prompt = Prompt(\n                    \"\",\n                    self.model,\n                    tools=current_response.prompt.tools,\n                    tool_results=tool_results,\n                    options=self.prompt.options,\n                    attachments=attachments,\n                )\n                current_response = AsyncResponse(\n                    prompt,\n                    self.model,\n                    stream=self.stream,\n                    key=self._key,\n                    conversation=self.conversation,\n                )\n            else:\n                current_response = None\n                break\n\n    async def __aiter__(self) -> AsyncIterator[str]:\n        async for response_item in self.responses():\n            async for chunk in response_item:\n                yield chunk\n\n    async def text(self) -> str:\n        all_chunks = []\n        async for chunk in self:\n            all_chunks.append(chunk)\n        return \"\".join(all_chunks)\n\n\nclass Options(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n\n_Options = Options\n\n\nclass _get_key_mixin:\n    needs_key: Optional[str] = None\n    key: Optional[str] = None\n    key_env_var: Optional[str] = None\n\n    def get_key(self, explicit_key: Optional[str] = None) -> Optional[str]:\n        from llm import get_key\n\n        if self.needs_key is None:\n            # This model doesn't use an API key\n            return None\n\n        if self.key is not None:\n            # Someone already set model.key='...'\n            return self.key\n\n        # Attempt to load a key using llm.get_key()\n        key_value = get_key(\n            explicit_key=explicit_key,\n            key_alias=self.needs_key,\n            env_var=self.key_env_var,\n        )\n        if key_value:\n            return key_value\n\n        # Show a useful error message\n        message = \"No key found - add one using 'llm keys set {}'\".format(\n            self.needs_key\n        )\n        if self.key_env_var:\n            message += \" or set the {} environment variable\".format(self.key_env_var)\n        raise NeedsKeyException(message)\n\n\nclass _BaseModel(ABC, _get_key_mixin):\n    model_id: str\n    can_stream: bool = False\n    attachment_types: Set = set()\n\n    supports_schema = False\n    supports_tools = False\n\n    class Options(_Options):\n        pass\n\n    def _validate_attachments(\n        self, attachments: Optional[List[Attachment]] = None\n    ) -> None:\n        if attachments and not self.attachment_types:\n            raise ValueError(\"This model does not support attachments\")\n        for attachment in attachments or []:\n            attachment_type = attachment.resolve_type()\n            if attachment_type not in self.attachment_types:\n                raise ValueError(\n                    f\"This model does not support attachments of type '{attachment_type}', \"\n                    f\"only {', '.join(self.attachment_types)}\"\n                )\n\n    def __str__(self) -> str:\n        return \"{}{}: {}\".format(\n            self.__class__.__name__,\n            \" (async)\" if isinstance(self, (AsyncModel, AsyncKeyModel)) else \"\",\n            self.model_id,\n        )\n\n    def __repr__(self) -> str:\n        return f\"<{str(self)}>\"\n\n\nclass _Model(_BaseModel):\n    def conversation(\n        self,\n        tools: Optional[List[ToolDef]] = None,\n        before_call: Optional[BeforeCallSync] = None,\n        after_call: Optional[AfterCallSync] = None,\n        chain_limit: Optional[int] = None,\n    ) -> Conversation:\n        return Conversation(\n            model=self,\n            tools=tools,\n            before_call=before_call,\n            after_call=after_call,\n            chain_limit=chain_limit,\n        )\n\n    def prompt(\n        self,\n        prompt: Optional[str] = None,\n        *,\n        fragments: Optional[List[Union[str, Fragment]]] = None,\n        attachments: Optional[List[Attachment]] = None,\n        system: Optional[str] = None,\n        system_fragments: Optional[List[Union[str, Fragment]]] = None,\n        stream: bool = True,\n        schema: Optional[Union[dict, type[BaseModel]]] = None,\n        tools: Optional[List[ToolDef]] = None,\n        tool_results: Optional[List[ToolResult]] = None,\n        **options,\n    ) -> Response:\n        key_value = options.pop(\"key\", None)\n        self._validate_attachments(attachments)\n        return Response(\n            Prompt(\n                prompt,\n                fragments=fragments,\n                attachments=attachments,\n                system=system,\n                schema=schema,\n                tools=tools,\n                tool_results=tool_results,\n                system_fragments=system_fragments,\n                model=self,\n                options=self.Options(**options),\n            ),\n            self,\n            stream,\n            key=key_value,\n        )\n\n    def chain(\n        self,\n        prompt: Optional[str] = None,\n        *,\n        fragments: Optional[List[str]] = None,\n        attachments: Optional[List[Attachment]] = None,\n        system: Optional[str] = None,\n        system_fragments: Optional[List[str]] = None,\n        stream: bool = True,\n        schema: Optional[Union[dict, type[BaseModel]]] = None,\n        tools: Optional[List[ToolDef]] = None,\n        tool_results: Optional[List[ToolResult]] = None,\n        before_call: Optional[BeforeCallSync] = None,\n        after_call: Optional[AfterCallSync] = None,\n        key: Optional[str] = None,\n        options: Optional[dict] = None,\n    ) -> ChainResponse:\n        return self.conversation().chain(\n            prompt=prompt,\n            fragments=fragments,\n            attachments=attachments,\n            system=system,\n            system_fragments=system_fragments,\n            stream=stream,\n            schema=schema,\n            tools=tools,\n            tool_results=tool_results,\n            before_call=before_call,\n            after_call=after_call,\n            key=key,\n            options=options,\n        )\n\n\nclass Model(_Model):\n    @abstractmethod\n    def execute(\n        self,\n        prompt: Prompt,\n        stream: bool,\n        response: Response,\n        conversation: Optional[Conversation],\n    ) -> Iterator[str]:\n        pass\n\n\nclass KeyModel(_Model):\n    @abstractmethod\n    def execute(\n        self,\n        prompt: Prompt,\n        stream: bool,\n        response: Response,\n        conversation: Optional[Conversation],\n        key: Optional[str],\n    ) -> Iterator[str]:\n        pass\n\n\nclass _AsyncModel(_BaseModel):\n    def conversation(\n        self,\n        tools: Optional[List[ToolDef]] = None,\n        before_call: Optional[BeforeCallAsync] = None,\n        after_call: Optional[AfterCallAsync] = None,\n        chain_limit: Optional[int] = None,\n    ) -> AsyncConversation:\n        return AsyncConversation(\n            model=self,\n            tools=tools,\n            before_call=before_call,\n            after_call=after_call,\n            chain_limit=chain_limit,\n        )\n\n    def prompt(\n        self,\n        prompt: Optional[str] = None,\n        *,\n        fragments: Optional[List[Union[str, Fragment]]] = None,\n        attachments: Optional[List[Attachment]] = None,\n        system: Optional[str] = None,\n        schema: Optional[Union[dict, type[BaseModel]]] = None,\n        tools: Optional[List[ToolDef]] = None,\n        tool_results: Optional[List[ToolResult]] = None,\n        system_fragments: Optional[List[Union[str, Fragment]]] = None,\n        stream: bool = True,\n        **options,\n    ) -> AsyncResponse:\n        key_value = options.pop(\"key\", None)\n        self._validate_attachments(attachments)\n        return AsyncResponse(\n            Prompt(\n                prompt,\n                fragments=fragments,\n                attachments=attachments,\n                system=system,\n                schema=schema,\n                tools=tools,\n                tool_results=tool_results,\n                system_fragments=system_fragments,\n                model=self,\n                options=self.Options(**options),\n            ),\n            self,\n            stream,\n            key=key_value,\n        )\n\n    def chain(\n        self,\n        prompt: Optional[str] = None,\n        *,\n        fragments: Optional[List[str]] = None,\n        attachments: Optional[List[Attachment]] = None,\n        system: Optional[str] = None,\n        system_fragments: Optional[List[str]] = None,\n        stream: bool = True,\n        schema: Optional[Union[dict, type[BaseModel]]] = None,\n        tools: Optional[List[ToolDef]] = None,\n        tool_results: Optional[List[ToolResult]] = None,\n        before_call: Optional[BeforeCallAsync] = None,\n        after_call: Optional[AfterCallAsync] = None,\n        key: Optional[str] = None,\n        options: Optional[dict] = None,\n    ) -> AsyncChainResponse:\n        return self.conversation().chain(\n            prompt=prompt,\n            fragments=fragments,\n            attachments=attachments,\n            system=system,\n            system_fragments=system_fragments,\n            stream=stream,\n            schema=schema,\n            tools=tools,\n            tool_results=tool_results,\n            before_call=before_call,\n            after_call=after_call,\n            key=key,\n            options=options,\n        )\n\n\nclass AsyncModel(_AsyncModel):\n    @abstractmethod\n    async def execute(\n        self,\n        prompt: Prompt,\n        stream: bool,\n        response: AsyncResponse,\n        conversation: Optional[AsyncConversation],\n    ) -> AsyncGenerator[str, None]:\n        if False:  # Ensure it's a generator type\n            yield \"\"\n        pass\n\n\nclass AsyncKeyModel(_AsyncModel):\n    @abstractmethod\n    async def execute(\n        self,\n        prompt: Prompt,\n        stream: bool,\n        response: AsyncResponse,\n        conversation: Optional[AsyncConversation],\n        key: Optional[str],\n    ) -> AsyncGenerator[str, None]:\n        if False:  # Ensure it's a generator type\n            yield \"\"\n        pass\n\n\nclass EmbeddingModel(ABC, _get_key_mixin):\n    model_id: str\n    key: Optional[str] = None\n    needs_key: Optional[str] = None\n    key_env_var: Optional[str] = None\n    supports_text: bool = True\n    supports_binary: bool = False\n    batch_size: Optional[int] = None\n\n    def _check(self, item: Union[str, bytes]):\n        if not self.supports_binary and isinstance(item, bytes):\n            raise ValueError(\n                \"This model does not support binary data, only text strings\"\n            )\n        if not self.supports_text and isinstance(item, str):\n            raise ValueError(\n                \"This model does not support text strings, only binary data\"\n            )\n\n    def embed(self, item: Union[str, bytes]) -> List[float]:\n        \"Embed a single text string or binary blob, return a list of floats\"\n        self._check(item)\n        return next(iter(self.embed_batch([item])))\n\n    def embed_multi(\n        self, items: Iterable[Union[str, bytes]], batch_size: Optional[int] = None\n    ) -> Iterator[List[float]]:\n        \"Embed multiple items in batches according to the model batch_size\"\n        iter_items = iter(items)\n        effective_batch_size = self.batch_size if batch_size is None else batch_size\n        if (not self.supports_binary) or (not self.supports_text):\n\n            def checking_iter(inner_items):\n                for item_to_check in inner_items:\n                    self._check(item_to_check)\n                    yield item_to_check\n\n            iter_items = checking_iter(items)\n        if effective_batch_size is None:\n            yield from self.embed_batch(iter_items)\n            return\n        while True:\n            batch_items = list(islice(iter_items, effective_batch_size))\n            if not batch_items:\n                break\n            yield from self.embed_batch(batch_items)\n\n    @abstractmethod\n    def embed_batch(self, items: Iterable[Union[str, bytes]]) -> Iterator[List[float]]:\n        \"\"\"\n        Embed a batch of strings or blobs, return a list of lists of floats\n        \"\"\"\n        pass\n\n    def __str__(self) -> str:\n        return \"{}: {}\".format(self.__class__.__name__, self.model_id)\n\n    def __repr__(self) -> str:\n        return f\"<{str(self)}>\"\n\n\n@dataclass\nclass ModelWithAliases:\n    model: Model\n    async_model: AsyncModel\n    aliases: Set[str]\n\n    def matches(self, query: str) -> bool:\n        query_lower = query.lower()\n        all_strings: List[str] = []\n        all_strings.extend(self.aliases)\n        if self.model:\n            all_strings.append(str(self.model))\n        if self.async_model:\n            all_strings.append(str(self.async_model.model_id))\n        return any(query_lower in alias.lower() for alias in all_strings)\n\n\n@dataclass\nclass EmbeddingModelWithAliases:\n    model: EmbeddingModel\n    aliases: Set[str]\n\n    def matches(self, query: str) -> bool:\n        query_lower = query.lower()\n        all_strings: List[str] = []\n        all_strings.extend(self.aliases)\n        all_strings.append(str(self.model))\n        return any(query_lower in alias.lower() for alias in all_strings)\n\n\ndef _conversation_name(text):\n    # Collapse whitespace, including newlines\n    text = re.sub(r\"\\s+\", \" \", text)\n    if len(text) <= CONVERSATION_NAME_LENGTH:\n        return text\n    return text[: CONVERSATION_NAME_LENGTH - 1] + \"…\"\n\n\ndef _ensure_dict_schema(schema):\n    \"\"\"Convert a Pydantic model to a JSON schema dict if needed.\"\"\"\n    if schema and not isinstance(schema, dict) and issubclass(schema, BaseModel):\n        schema_dict = schema.model_json_schema()\n        _remove_titles_recursively(schema_dict)\n        return schema_dict\n    return schema\n\n\ndef _remove_titles_recursively(obj):\n    \"\"\"Recursively remove all 'title' fields from a nested dictionary.\"\"\"\n    if isinstance(obj, dict):\n        # Remove title if present\n        obj.pop(\"title\", None)\n\n        # Recursively process all values\n        for value in obj.values():\n            _remove_titles_recursively(value)\n    elif isinstance(obj, list):\n        # Process each item in lists\n        for item in obj:\n            _remove_titles_recursively(item)\n\n\ndef _get_instance(implementation):\n    if hasattr(implementation, \"__self__\"):\n        return implementation.__self__\n    return None\n"
  },
  {
    "path": "llm/plugins.py",
    "content": "import importlib\nfrom importlib import metadata\nimport os\nimport pluggy\nimport sys\nfrom . import hookspecs\n\nDEFAULT_PLUGINS = (\n    \"llm.default_plugins.openai_models\",\n    \"llm.default_plugins.default_tools\",\n)\n\npm = pluggy.PluginManager(\"llm\")\npm.add_hookspecs(hookspecs)\n\nLLM_LOAD_PLUGINS = os.environ.get(\"LLM_LOAD_PLUGINS\", None)\n\n_loaded = False\n\n\ndef load_plugins():\n    global _loaded\n    if _loaded:\n        return\n    _loaded = True\n    if not hasattr(sys, \"_called_from_test\") and LLM_LOAD_PLUGINS is None:\n        # Only load plugins if not running tests\n        pm.load_setuptools_entrypoints(\"llm\")\n\n    # Load any plugins specified in LLM_LOAD_PLUGINS\")\n    if LLM_LOAD_PLUGINS is not None:\n        for package_name in [\n            name for name in LLM_LOAD_PLUGINS.split(\",\") if name.strip()\n        ]:\n            try:\n                distribution = metadata.distribution(package_name)  # Updated call\n                llm_entry_points = [\n                    ep for ep in distribution.entry_points if ep.group == \"llm\"\n                ]\n                for entry_point in llm_entry_points:\n                    mod = entry_point.load()\n                    pm.register(mod, name=entry_point.name)\n                    # Ensure name can be found in plugin_to_distinfo later:\n                    pm._plugin_distinfo.append((mod, distribution))  # type: ignore\n            except metadata.PackageNotFoundError:\n                sys.stderr.write(f\"Plugin {package_name} could not be found\\n\")\n\n    for plugin in DEFAULT_PLUGINS:\n        mod = importlib.import_module(plugin)\n        pm.register(mod, plugin)\n"
  },
  {
    "path": "llm/py.typed",
    "content": ""
  },
  {
    "path": "llm/templates.py",
    "content": "from pydantic import BaseModel, ConfigDict\nimport string\nfrom typing import Optional, Any, Dict, List, Tuple\n\n\nclass AttachmentType(BaseModel):\n    type: str\n    value: str\n\n\nclass Template(BaseModel):\n    name: str\n    prompt: Optional[str] = None\n    system: Optional[str] = None\n    attachments: Optional[List[str]] = None\n    attachment_types: Optional[List[AttachmentType]] = None\n    model: Optional[str] = None\n    defaults: Optional[Dict[str, Any]] = None\n    options: Optional[Dict[str, Any]] = None\n    extract: Optional[bool] = None  # For extracting fenced code blocks\n    extract_last: Optional[bool] = None\n    schema_object: Optional[dict] = None\n    fragments: Optional[List[str]] = None\n    system_fragments: Optional[List[str]] = None\n    tools: Optional[List[str]] = None\n    functions: Optional[str] = None\n\n    model_config = ConfigDict(extra=\"forbid\")\n\n    class MissingVariables(Exception):\n        pass\n\n    def __init__(self, **data):\n        super().__init__(**data)\n        # Not a pydantic field to avoid YAML being able to set it\n        # this controls if Python inline functions code is trusted\n        self._functions_is_trusted = False\n\n    def evaluate(\n        self, input: str, params: Optional[Dict[str, Any]] = None\n    ) -> Tuple[Optional[str], Optional[str]]:\n        params = params or {}\n        params[\"input\"] = input\n        if self.defaults:\n            for k, v in self.defaults.items():\n                if k not in params:\n                    params[k] = v\n        prompt: Optional[str] = None\n        system: Optional[str] = None\n        if not self.prompt:\n            system = self.interpolate(self.system, params)\n            prompt = input\n        else:\n            prompt = self.interpolate(self.prompt, params)\n            system = self.interpolate(self.system, params)\n        return prompt, system\n\n    def vars(self) -> set:\n        all_vars = set()\n        for text in [self.prompt, self.system]:\n            if not text:\n                continue\n            all_vars.update(self.extract_vars(string.Template(text)))\n        return all_vars\n\n    @classmethod\n    def interpolate(cls, text: Optional[str], params: Dict[str, Any]) -> Optional[str]:\n        if not text:\n            return text\n        # Confirm all variables in text are provided\n        string_template = string.Template(text)\n        vars = cls.extract_vars(string_template)\n        missing = [p for p in vars if p not in params]\n        if missing:\n            raise cls.MissingVariables(\n                \"Missing variables: {}\".format(\", \".join(missing))\n            )\n        return string_template.substitute(**params)\n\n    @staticmethod\n    def extract_vars(string_template: string.Template) -> List[str]:\n        return [\n            match.group(\"named\")\n            for match in string_template.pattern.finditer(string_template.template)\n            if match.group(\"named\")\n        ]\n"
  },
  {
    "path": "llm/tools.py",
    "content": "from datetime import datetime, timezone\nfrom importlib.metadata import version\nimport time\n\n\ndef llm_version() -> str:\n    \"Return the installed version of llm\"\n    return version(\"llm\")\n\n\ndef llm_time() -> dict:\n    \"Returns the current time, as local time and UTC\"\n    # Get current times\n    utc_time = datetime.now(timezone.utc)\n    local_time = datetime.now()\n\n    # Get timezone information\n    local_tz_name = time.tzname[time.localtime().tm_isdst]\n    is_dst = bool(time.localtime().tm_isdst)\n\n    # Calculate offset\n    offset_seconds = -time.timezone if not is_dst else -time.altzone\n    offset_hours = offset_seconds // 3600\n    offset_minutes = (offset_seconds % 3600) // 60\n\n    timezone_offset = (\n        f\"UTC{'+' if offset_hours >= 0 else ''}{offset_hours:02d}:{offset_minutes:02d}\"\n    )\n\n    return {\n        \"utc_time\": utc_time.strftime(\"%Y-%m-%d %H:%M:%S UTC\"),\n        \"utc_time_iso\": utc_time.isoformat(),\n        \"local_timezone\": local_tz_name,\n        \"local_time\": local_time.strftime(\"%Y-%m-%d %H:%M:%S\"),\n        \"timezone_offset\": timezone_offset,\n        \"is_dst\": is_dst,\n    }\n"
  },
  {
    "path": "llm/utils.py",
    "content": "import click\nimport hashlib\nimport httpx\nimport itertools\nimport json\nimport pathlib\nimport puremagic\nimport re\nimport sqlite_utils\nimport textwrap\nfrom typing import Any, List, Dict, Optional, Tuple, Type\nimport os\nimport threading\nimport time\nfrom typing import Final\n\nfrom ulid import ULID\n\nMIME_TYPE_FIXES = {\n    \"audio/wave\": \"audio/wav\",\n}\n\n\nclass Fragment(str):\n    def __new__(cls, content, *args, **kwargs):\n        # For immutable classes like str, __new__ creates the string object\n        return super().__new__(cls, content)\n\n    def __init__(self, content, source=\"\"):\n        # Initialize our custom attributes\n        self.source = source\n\n    def id(self):\n        return hashlib.sha256(self.encode(\"utf-8\")).hexdigest()\n\n\ndef mimetype_from_string(content) -> Optional[str]:\n    try:\n        type_ = puremagic.from_string(content, mime=True)\n        return MIME_TYPE_FIXES.get(type_, type_)\n    except puremagic.PureError:\n        return None\n\n\ndef mimetype_from_path(path) -> Optional[str]:\n    try:\n        type_ = puremagic.from_file(path, mime=True)\n        return MIME_TYPE_FIXES.get(type_, type_)\n    except puremagic.PureError:\n        return None\n\n\ndef dicts_to_table_string(\n    headings: List[str], dicts: List[Dict[str, str]]\n) -> List[str]:\n    max_lengths = [len(h) for h in headings]\n\n    # Compute maximum length for each column\n    for d in dicts:\n        for i, h in enumerate(headings):\n            if h in d and len(str(d[h])) > max_lengths[i]:\n                max_lengths[i] = len(str(d[h]))\n\n    # Generate formatted table strings\n    res = []\n    res.append(\"    \".join(h.ljust(max_lengths[i]) for i, h in enumerate(headings)))\n\n    for d in dicts:\n        row = []\n        for i, h in enumerate(headings):\n            row.append(str(d.get(h, \"\")).ljust(max_lengths[i]))\n        res.append(\"    \".join(row))\n\n    return res\n\n\ndef remove_dict_none_values(d):\n    \"\"\"\n    Recursively remove keys with value of None or value of a dict that is all values of None\n    \"\"\"\n    if not isinstance(d, dict):\n        return d\n    new_dict = {}\n    for key, value in d.items():\n        if value is not None:\n            if isinstance(value, dict):\n                nested = remove_dict_none_values(value)\n                if nested:\n                    new_dict[key] = nested\n            elif isinstance(value, list):\n                new_dict[key] = [remove_dict_none_values(v) for v in value]\n            else:\n                new_dict[key] = value\n    return new_dict\n\n\nclass _LogResponse(httpx.Response):\n    def iter_bytes(self, *args, **kwargs):\n        for chunk in super().iter_bytes(*args, **kwargs):\n            click.echo(chunk.decode(), err=True)\n            yield chunk\n\n\nclass _LogTransport(httpx.BaseTransport):\n    def __init__(self, transport: httpx.BaseTransport):\n        self.transport = transport\n\n    def handle_request(self, request: httpx.Request) -> httpx.Response:\n        response = self.transport.handle_request(request)\n        return _LogResponse(\n            status_code=response.status_code,\n            headers=response.headers,\n            stream=response.stream,\n            extensions=response.extensions,\n        )\n\n\ndef _no_accept_encoding(request: httpx.Request):\n    request.headers.pop(\"accept-encoding\", None)\n\n\ndef _log_response(response: httpx.Response):\n    request = response.request\n    click.echo(f\"Request: {request.method} {request.url}\", err=True)\n    click.echo(\"  Headers:\", err=True)\n    for key, value in request.headers.items():\n        if key.lower() == \"authorization\":\n            value = \"[...]\"\n        if key.lower() == \"cookie\":\n            value = value.split(\"=\")[0] + \"=...\"\n        click.echo(f\"    {key}: {value}\", err=True)\n    click.echo(\"  Body:\", err=True)\n    try:\n        request_body = json.loads(request.content)\n        click.echo(\n            textwrap.indent(json.dumps(request_body, indent=2), \"    \"), err=True\n        )\n    except json.JSONDecodeError:\n        click.echo(textwrap.indent(request.content.decode(), \"    \"), err=True)\n    click.echo(f\"Response: status_code={response.status_code}\", err=True)\n    click.echo(\"  Headers:\", err=True)\n    for key, value in response.headers.items():\n        if key.lower() == \"set-cookie\":\n            value = value.split(\"=\")[0] + \"=...\"\n        click.echo(f\"    {key}: {value}\", err=True)\n    click.echo(\"  Body:\", err=True)\n\n\ndef logging_client() -> httpx.Client:\n    return httpx.Client(\n        transport=_LogTransport(httpx.HTTPTransport()),\n        event_hooks={\"request\": [_no_accept_encoding], \"response\": [_log_response]},\n    )\n\n\ndef simplify_usage_dict(d):\n    # Recursively remove keys with value 0 and empty dictionaries\n    def remove_empty_and_zero(obj):\n        if isinstance(obj, dict):\n            cleaned = {\n                k: remove_empty_and_zero(v)\n                for k, v in obj.items()\n                if v != 0 and v != {}\n            }\n            return {k: v for k, v in cleaned.items() if v is not None and v != {}}\n        return obj\n\n    return remove_empty_and_zero(d) or {}\n\n\ndef token_usage_string(input_tokens, output_tokens, token_details) -> str:\n    bits = []\n    if input_tokens is not None:\n        bits.append(f\"{format(input_tokens, ',')} input\")\n    if output_tokens is not None:\n        bits.append(f\"{format(output_tokens, ',')} output\")\n    if token_details:\n        bits.append(json.dumps(token_details))\n    return \", \".join(bits)\n\n\ndef extract_fenced_code_block(text: str, last: bool = False) -> Optional[str]:\n    \"\"\"\n    Extracts and returns Markdown fenced code block found in the given text.\n\n    The function handles fenced code blocks that:\n    - Use at least three backticks (`).\n    - May include a language tag immediately after the opening backticks.\n    - Use more than three backticks as long as the closing fence has the same number.\n\n    If no fenced code block is found, the function returns None.\n\n    Args:\n        text (str): The input text to search for a fenced code block.\n        last (bool): Extract the last code block if True, otherwise the first.\n\n    Returns:\n        Optional[str]: The content of the fenced code block, or None if not found.\n    \"\"\"\n    # Regex pattern to match fenced code blocks\n    # - ^ or \\n ensures that the fence is at the start of a line\n    # - (`{3,}) captures the opening backticks (at least three)\n    # - (\\w+)? optionally captures the language tag\n    # - \\n matches the newline after the opening fence\n    # - (.*?) non-greedy match for the code block content\n    # - (?P=fence) ensures that the closing fence has the same number of backticks\n    # - [ ]* allows for optional spaces between the closing fence and newline\n    # - (?=\\n|$) ensures that the closing fence is followed by a newline or end of string\n    pattern = re.compile(\n        r\"\"\"(?m)^(?P<fence>`{3,})(?P<lang>\\w+)?\\n(?P<code>.*?)^(?P=fence)[ ]*(?=\\n|$)\"\"\",\n        re.DOTALL,\n    )\n    matches = list(pattern.finditer(text))\n    if matches:\n        match = matches[-1] if last else matches[0]\n        return match.group(\"code\")\n    return None\n\n\ndef make_schema_id(schema: dict) -> Tuple[str, str]:\n    schema_json = json.dumps(schema, separators=(\",\", \":\"))\n    schema_id = hashlib.blake2b(schema_json.encode(), digest_size=16).hexdigest()\n    return schema_id, schema_json\n\n\ndef output_rows_as_json(rows, nl=False, compact=False, json_cols=()):\n    \"\"\"\n    Output rows as JSON - either newline-delimited or an array\n\n    Parameters:\n    - rows: Iterable of dictionaries to output\n    - nl: Boolean, if True, use newline-delimited JSON\n    - compact: Boolean, if True uses [{\"...\": \"...\"}\\n {\"...\": \"...\"}] format\n    - json_cols: Iterable of columns that contain JSON\n\n    Yields:\n    - Stream of strings to be output\n    \"\"\"\n    current_iter, next_iter = itertools.tee(rows, 2)\n    next(next_iter, None)\n    first = True\n\n    for row, next_row in itertools.zip_longest(current_iter, next_iter):\n        is_last = next_row is None\n        for col in json_cols:\n            row[col] = json.loads(row[col])\n\n        if nl:\n            # Newline-delimited JSON: one JSON object per line\n            yield json.dumps(row)\n        elif compact:\n            # Compact array format: [{\"...\": \"...\"}\\n {\"...\": \"...\"}]\n            yield \"{firstchar}{serialized}{maybecomma}{lastchar}\".format(\n                firstchar=\"[\" if first else \" \",\n                serialized=json.dumps(row),\n                maybecomma=\",\" if not is_last else \"\",\n                lastchar=\"]\" if is_last else \"\",\n            )\n        else:\n            # Pretty-printed array format with indentation\n            yield \"{firstchar}{serialized}{maybecomma}{lastchar}\".format(\n                firstchar=\"[\\n\" if first else \"\",\n                serialized=textwrap.indent(json.dumps(row, indent=2), \"  \"),\n                maybecomma=\",\" if not is_last else \"\",\n                lastchar=\"\\n]\" if is_last else \"\",\n            )\n        first = False\n\n    if first and not nl:\n        # We didn't output any rows, so yield the empty list\n        yield \"[]\"\n\n\ndef resolve_schema_input(db, schema_input, load_template):\n    # schema_input might be JSON or a filepath or an ID or t:name\n    if not schema_input:\n        return\n    if schema_input.strip().startswith(\"t:\"):\n        name = schema_input.strip()[2:]\n        schema_object = None\n        try:\n            template = load_template(name)\n            schema_object = template.schema_object\n        except ValueError:\n            raise click.ClickException(\"Invalid template: {}\".format(name))\n        if not schema_object:\n            raise click.ClickException(\"Template '{}' has no schema\".format(name))\n        return template.schema_object\n    if schema_input.strip().startswith(\"{\"):\n        try:\n            return json.loads(schema_input)\n        except ValueError:\n            pass\n    if \" \" in schema_input.strip() or \",\" in schema_input:\n        # Treat it as schema DSL\n        return schema_dsl(schema_input)\n    # Is it a file on disk?\n    path = pathlib.Path(schema_input)\n    if path.exists():\n        try:\n            return json.loads(path.read_text())\n        except ValueError:\n            raise click.ClickException(\"Schema file contained invalid JSON\")\n    # Last attempt: is it an ID in the DB?\n    try:\n        row = db[\"schemas\"].get(schema_input)\n        return json.loads(row[\"content\"])\n    except (sqlite_utils.db.NotFoundError, ValueError):\n        raise click.BadParameter(\"Invalid schema\")\n\n\ndef schema_summary(schema: dict) -> str:\n    \"\"\"\n    Extract property names from a JSON schema and format them in a\n    concise way that highlights the array/object structure.\n\n    Args:\n        schema (dict): A JSON schema dictionary\n\n    Returns:\n        str: A human-friendly summary of the schema structure\n    \"\"\"\n    if not schema or not isinstance(schema, dict):\n        return \"\"\n\n    schema_type = schema.get(\"type\", \"\")\n\n    if schema_type == \"object\":\n        props = schema.get(\"properties\", {})\n        prop_summaries = []\n\n        for name, prop_schema in props.items():\n            prop_type = prop_schema.get(\"type\", \"\")\n\n            if prop_type == \"array\":\n                items = prop_schema.get(\"items\", {})\n                items_summary = schema_summary(items)\n                prop_summaries.append(f\"{name}: [{items_summary}]\")\n            elif prop_type == \"object\":\n                nested_summary = schema_summary(prop_schema)\n                prop_summaries.append(f\"{name}: {nested_summary}\")\n            else:\n                prop_summaries.append(name)\n\n        return \"{\" + \", \".join(prop_summaries) + \"}\"\n\n    elif schema_type == \"array\":\n        items = schema.get(\"items\", {})\n        return schema_summary(items)\n\n    return \"\"\n\n\ndef schema_dsl(schema_dsl: str, multi: bool = False) -> Dict[str, Any]:\n    \"\"\"\n    Build a JSON schema from a concise schema string.\n\n    Args:\n        schema_dsl: A string representing a schema in the concise format.\n            Can be comma-separated or newline-separated.\n        multi: Boolean, return a schema for an \"items\" array of these\n\n    Returns:\n        A dictionary representing the JSON schema.\n    \"\"\"\n    # Type mapping dictionary\n    type_mapping = {\n        \"int\": \"integer\",\n        \"float\": \"number\",\n        \"bool\": \"boolean\",\n        \"str\": \"string\",\n    }\n\n    # Initialize the schema dictionary with required elements\n    json_schema: Dict[str, Any] = {\"type\": \"object\", \"properties\": {}, \"required\": []}\n\n    # Check if the schema is newline-separated or comma-separated\n    if \"\\n\" in schema_dsl:\n        fields = [field.strip() for field in schema_dsl.split(\"\\n\") if field.strip()]\n    else:\n        fields = [field.strip() for field in schema_dsl.split(\",\") if field.strip()]\n\n    # Process each field\n    for field in fields:\n        # Extract field name, type, and description\n        if \":\" in field:\n            field_info, description = field.split(\":\", 1)\n            description = description.strip()\n        else:\n            field_info = field\n            description = \"\"\n\n        # Process field name and type\n        field_parts = field_info.strip().split()\n        field_name = field_parts[0].strip()\n\n        # Default type is string\n        field_type = \"string\"\n\n        # If type is specified, use it\n        if len(field_parts) > 1:\n            type_indicator = field_parts[1].strip()\n            if type_indicator in type_mapping:\n                field_type = type_mapping[type_indicator]\n\n        # Add field to properties\n        json_schema[\"properties\"][field_name] = {\"type\": field_type}\n\n        # Add description if provided\n        if description:\n            json_schema[\"properties\"][field_name][\"description\"] = description\n\n        # Add field to required list\n        json_schema[\"required\"].append(field_name)\n\n    if multi:\n        return multi_schema(json_schema)\n    else:\n        return json_schema\n\n\ndef multi_schema(schema: dict) -> dict:\n    \"Wrap JSON schema in an 'items': [] array\"\n    return {\n        \"type\": \"object\",\n        \"properties\": {\"items\": {\"type\": \"array\", \"items\": schema}},\n        \"required\": [\"items\"],\n    }\n\n\ndef find_unused_key(item: dict, key: str) -> str:\n    'Return unused key, e.g. for {\"id\": \"1\"} and key \"id\" returns \"id_\"'\n    while key in item:\n        key += \"_\"\n    return key\n\n\ndef truncate_string(\n    text: str,\n    max_length: int = 100,\n    normalize_whitespace: bool = False,\n    keep_end: bool = False,\n) -> str:\n    \"\"\"\n    Truncate a string to a maximum length, with options to normalize whitespace and keep both start and end.\n\n    Args:\n        text: The string to truncate\n        max_length: Maximum length of the result string\n        normalize_whitespace: If True, replace all whitespace with a single space\n        keep_end: If True, keep both beginning and end of string\n\n    Returns:\n        Truncated string\n    \"\"\"\n    if not text:\n        return text\n\n    if normalize_whitespace:\n        text = re.sub(r\"\\s+\", \" \", text)\n\n    if len(text) <= max_length:\n        return text\n\n    # Minimum sensible length for keep_end is 9 characters: \"a... z\"\n    min_keep_end_length = 9\n\n    if keep_end and max_length >= min_keep_end_length:\n        # Calculate how much text to keep at each end\n        # Subtract 5 for the \"... \" separator\n        cutoff = (max_length - 5) // 2\n        return text[:cutoff] + \"... \" + text[-cutoff:]\n    else:\n        # Fall back to simple truncation for very small max_length\n        return text[: max_length - 3] + \"...\"\n\n\ndef ensure_fragment(db, content):\n    sql = \"\"\"\n    insert into fragments (hash, content, datetime_utc, source)\n    values (:hash, :content, datetime('now'), :source)\n    on conflict(hash) do nothing\n    \"\"\"\n    hash_id = hashlib.sha256(content.encode(\"utf-8\")).hexdigest()\n    source = None\n    if isinstance(content, Fragment):\n        source = content.source\n    with db.conn:\n        db.execute(sql, {\"hash\": hash_id, \"content\": content, \"source\": source})\n        return list(\n            db.query(\"select id from fragments where hash = :hash\", {\"hash\": hash_id})\n        )[0][\"id\"]\n\n\ndef ensure_tool(db, tool):\n    sql = \"\"\"\n    insert into tools (hash, name, description, input_schema, plugin)\n    values (:hash, :name, :description, :input_schema, :plugin)\n    on conflict(hash) do nothing\n    \"\"\"\n    with db.conn:\n        db.execute(\n            sql,\n            {\n                \"hash\": tool.hash(),\n                \"name\": tool.name,\n                \"description\": tool.description,\n                \"input_schema\": json.dumps(tool.input_schema),\n                \"plugin\": tool.plugin,\n            },\n        )\n        return list(\n            db.query(\"select id from tools where hash = :hash\", {\"hash\": tool.hash()})\n        )[0][\"id\"]\n\n\ndef maybe_fenced_code(content: str) -> str:\n    \"Return the content as a fenced code block if it looks like code\"\n    is_code = False\n    if content.count(\"<\") > 10:\n        is_code = True\n    if not is_code:\n        # Are 90% of the lines under 120 chars?\n        lines = content.splitlines()\n        if len(lines) > 3:\n            num_short = sum(1 for line in lines if len(line) < 120)\n            if num_short / len(lines) > 0.9:\n                is_code = True\n    if is_code:\n        # Find number of backticks not already present\n        num_backticks = 3\n        while \"`\" * num_backticks in content:\n            num_backticks += 1\n        # Add backticks\n        content = (\n            \"\\n\"\n            + \"`\" * num_backticks\n            + \"\\n\"\n            + content.strip()\n            + \"\\n\"\n            + \"`\" * num_backticks\n        )\n    return content\n\n\n_plugin_prefix_re = re.compile(r\"^[a-zA-Z0-9_-]+:\")\n\n\ndef has_plugin_prefix(value: str) -> bool:\n    \"Check if value starts with alphanumeric prefix followed by a colon\"\n    return bool(_plugin_prefix_re.match(value))\n\n\ndef _parse_kwargs(arg_str: str) -> Dict[str, Any]:\n    \"\"\"Parse key=value pairs where each value is valid JSON.\"\"\"\n    tokens = []\n    buf = []\n    depth = 0\n    in_string = False\n    string_char = \"\"\n    escape = False\n\n    for ch in arg_str:\n        if in_string:\n            buf.append(ch)\n            if escape:\n                escape = False\n            elif ch == \"\\\\\":\n                escape = True\n            elif ch == string_char:\n                in_string = False\n        else:\n            if ch in \"\\\"'\":\n                in_string = True\n                string_char = ch\n                buf.append(ch)\n            elif ch in \"{[(\":\n                depth += 1\n                buf.append(ch)\n            elif ch in \"}])\":\n                depth -= 1\n                buf.append(ch)\n            elif ch == \",\" and depth == 0:\n                tokens.append(\"\".join(buf).strip())\n                buf = []\n            else:\n                buf.append(ch)\n    if buf:\n        tokens.append(\"\".join(buf).strip())\n\n    kwargs: Dict[str, Any] = {}\n    for token in tokens:\n        if not token:\n            continue\n        if \"=\" not in token:\n            raise ValueError(f\"Invalid keyword spec segment: '{token}'\")\n        key, value_str = token.split(\"=\", 1)\n        key = key.strip()\n        value_str = value_str.strip()\n        try:\n            value = json.loads(value_str)\n        except json.JSONDecodeError as e:\n            raise ValueError(f\"Value for '{key}' is not valid JSON: {value_str}\") from e\n        kwargs[key] = value\n    return kwargs\n\n\ndef instantiate_from_spec(class_map: Dict[str, Type], spec: str):\n    \"\"\"\n    Instantiate a class from a specification string with flexible argument formats.\n\n    This function parses a specification string that defines a class name and its\n    constructor arguments, then instantiates the class using the provided class\n    mapping. The specification supports multiple argument formats for flexibility.\n\n    Parameters\n    ----------\n    class_map : Dict[str, Type]\n        A mapping from class names (strings) to their corresponding class objects.\n        Only classes present in this mapping can be instantiated.\n    spec : str\n        A specification string defining the class to instantiate and its arguments.\n\n        Format: \"ClassName\" or \"ClassName(arguments)\"\n\n        Supported argument formats:\n        - Empty: ClassName() - calls constructor with no arguments\n        - JSON object: ClassName({\"key\": \"value\", \"other\": 42}) - unpacked as **kwargs\n        - Single JSON value: ClassName(\"hello\") or ClassName([1,2,3]) - passed as single positional argument\n        - Key-value pairs: ClassName(name=\"test\", count=5, items=[1,2]) - parsed as individual kwargs\n          where values must be valid JSON\n\n    Returns\n    -------\n    object\n        An instance of the specified class, constructed with the parsed arguments.\n\n    Raises\n    ------\n    ValueError\n        If the spec string format is invalid, if the class name is not found in\n        class_map, if JSON parsing fails, or if argument parsing encounters errors.\n    \"\"\"\n    m = re.fullmatch(r\"\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*(?:\\((.*)\\))?\\s*$\", spec)\n    if not m:\n        raise ValueError(f\"Invalid spec string: '{spec}'\")\n    class_name, arg_body = m.group(1), (m.group(2) or \"\").strip()\n    if class_name not in class_map:\n        raise ValueError(f\"Unknown class '{class_name}'\")\n\n    cls = class_map[class_name]\n\n    # No arguments at all\n    if arg_body == \"\":\n        return cls()\n\n    # Starts with { -> JSON object to kwargs\n    if arg_body.lstrip().startswith(\"{\"):\n        try:\n            kw = json.loads(arg_body)\n        except json.JSONDecodeError as e:\n            raise ValueError(\"Argument JSON object is not valid JSON\") from e\n        if not isinstance(kw, dict):\n            raise ValueError(\"Top-level JSON must be an object when using {} form\")\n        return cls(**kw)\n\n    # Starts with quote / number / [ / t f n for single positional JSON value\n    if re.match(r'\\s*([\"\\[\\d\\-]|true|false|null)', arg_body, re.I):\n        try:\n            positional_value = json.loads(arg_body)\n        except json.JSONDecodeError as e:\n            raise ValueError(\"Positional argument must be valid JSON\") from e\n        return cls(positional_value)\n\n    # Otherwise treat as key=value pairs\n    kwargs = _parse_kwargs(arg_body)\n    return cls(**kwargs)\n\n\nNANOSECS_IN_MILLISECS = 1000000\nTIMESTAMP_LEN = 6\nRANDOMNESS_LEN = 10\n\n_lock: Final = threading.Lock()\n_last: Optional[bytes] = None  # 16-byte last produced ULID\n\n\ndef monotonic_ulid() -> ULID:\n    \"\"\"\n    Return a ULID instance that is guaranteed to be *strictly larger* than every\n    other ULID returned by this function inside the same process.\n\n    It works the same way the reference JavaScript `monotonicFactory` does:\n    * If the current call happens in the same millisecond as the previous\n        one, the 80-bit randomness part is incremented by exactly one.\n    * As soon as the system clock moves forward, a brand-new ULID with\n        cryptographically secure randomness is generated.\n    * If more than 2**80 ULIDs are requested within a single millisecond\n        an `OverflowError` is raised (practically impossible).\n    \"\"\"\n    global _last\n\n    now_ms = time.time_ns() // NANOSECS_IN_MILLISECS\n\n    with _lock:\n        # First call\n        if _last is None:\n            _last = _fresh(now_ms)\n            return ULID(_last)\n\n        # Decode timestamp from the last ULID we handed out\n        last_ms = int.from_bytes(_last[:TIMESTAMP_LEN], \"big\")\n\n        # If the millisecond is the same, increment the randomness\n        if now_ms == last_ms:\n            rand_int = int.from_bytes(_last[TIMESTAMP_LEN:], \"big\") + 1\n            if rand_int >= 1 << (RANDOMNESS_LEN * 8):\n                raise OverflowError(\n                    \"Randomness overflow: > 2**80 ULIDs requested \"\n                    \"in one millisecond!\"\n                )\n            randomness = rand_int.to_bytes(RANDOMNESS_LEN, \"big\")\n            _last = _last[:TIMESTAMP_LEN] + randomness\n            return ULID(_last)\n\n        # New millisecond, start fresh\n        _last = _fresh(now_ms)\n        return ULID(_last)\n\n\ndef _fresh(ms: int) -> bytes:\n    \"\"\"Build a brand-new 16-byte ULID for the given millisecond.\"\"\"\n    timestamp = int.to_bytes(ms, TIMESTAMP_LEN, \"big\")\n    randomness = os.urandom(RANDOMNESS_LEN)\n    return timestamp + randomness\n"
  },
  {
    "path": "mypy.ini",
    "content": "[mypy]\n\n[mypy-pluggy.*]\nignore_missing_imports = True\n\n[mypy-click_default_group.*]\nignore_missing_imports = True\n\n[mypy-sqlite_migrate.*]\nignore_missing_imports = True\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"llm\"\nversion = \"0.29\"\ndescription = \"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.\"\nreadme = { file = \"README.md\", content-type = \"text/markdown\" }\nauthors = [\n    { name = \"Simon Willison\" },\n]\nlicense = \"Apache-2.0\"\nrequires-python = \">=3.10\"\nclassifiers = [\n    \"Development Status :: 4 - Beta\",\n    \"Intended Audience :: Developers\",\n    \"Intended Audience :: End Users/Desktop\",\n    \"Intended Audience :: Science/Research\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Programming Language :: Python :: 3.13\",\n    \"Programming Language :: Python :: 3.14\",\n    \"Topic :: Scientific/Engineering :: Artificial Intelligence\",\n    \"Topic :: Text Processing :: Linguistic\",\n    \"Topic :: Utilities\",\n]\n\ndependencies = [\n    \"click\",\n    \"condense-json>=0.1.3\",\n    \"openai>=1.55.3\",\n    \"click-default-group>=1.2.3\",\n    \"sqlite-utils>=3.37\",\n    \"sqlite-migrate>=0.1a2\",\n    \"pydantic>=2.0.0\",\n    \"PyYAML\",\n    \"pluggy\",\n    \"python-ulid\",\n    \"setuptools\",\n    \"pip\",\n    \"pyreadline3; sys_platform == 'win32'\",\n    \"puremagic\",\n]\n\n[dependency-groups]\ndev = [\n    \"build\",\n    \"click<8.2.0\", # https://github.com/simonw/llm/issues/1024\n    \"pytest\",\n    \"numpy\",\n    \"pytest-httpx>=0.33.0\",\n    \"pytest-asyncio\",\n    \"cogapp\",\n    \"mypy>=1.10.0\",\n    \"black>=25.1.0\",\n    \"pytest-recording\",\n    \"ruff\",\n    \"syrupy\",\n    \"types-click\",\n    \"types-PyYAML\",\n    \"types-setuptools\",\n    \"llm-echo==0.3a3\",\n    # docs\n    \"sphinx==7.2.6\",\n    \"furo==2023.9.10\",\n    \"sphinx-autobuild\",\n    \"sphinx-copybutton\",\n    \"sphinx-markdown-builder==0.6.8\",\n    \"myst-parser\",\n]\n\n[project.urls]\nHomepage = \"https://github.com/simonw/llm\"\nDocumentation = \"https://llm.datasette.io/\"\nIssues = \"https://github.com/simonw/llm/issues\"\nCI = \"https://github.com/simonw/llm/actions\"\nChangelog = \"https://github.com/simonw/llm/releases\"\n\n[project.scripts]\nllm = \"llm.cli:cli\"\n\n[build-system]\nrequires = [\"setuptools\"]\nbuild-backend = \"setuptools.build_meta\"\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\nasyncio_default_fixture_loop_scope = function"
  },
  {
    "path": "ruff.toml",
    "content": "line-length = 160\n"
  },
  {
    "path": "tests/cassettes/test_tools/test_tool_use_basic.yaml",
    "content": "interactions:\n- request:\n    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\n      two numbers.\",\"parameters\":{\"properties\":{\"a\":{\"type\":\"integer\"},\"b\":{\"type\":\"integer\"}},\"required\":[\"a\",\"b\"],\"type\":\"object\"}}}]}'\n    headers:\n      accept:\n      - application/json\n      accept-encoding:\n      - gzip, deflate\n      connection:\n      - keep-alive\n      content-length:\n      - '351'\n      content-type:\n      - application/json\n      host:\n      - api.openai.com\n      user-agent:\n      - OpenAI/Python 1.78.0\n      x-stainless-arch:\n      - arm64\n      x-stainless-async:\n      - 'false'\n      x-stainless-lang:\n      - python\n      x-stainless-os:\n      - MacOS\n      x-stainless-package-version:\n      - 1.78.0\n      x-stainless-read-timeout:\n      - '600'\n      x-stainless-retry-count:\n      - '0'\n      x-stainless-runtime:\n      - CPython\n      x-stainless-runtime-version:\n      - 3.13.3\n    method: POST\n    uri: https://api.openai.com/v1/chat/completions\n  response:\n    body:\n      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}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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}}}\n\n\n        data: [DONE]\n\n\n        '\n    headers:\n      CF-RAY:\n      - 93f2fd4c4ce5238d-SJC\n      Connection:\n      - keep-alive\n      Content-Type:\n      - text/event-stream; charset=utf-8\n      Date:\n      - Tue, 13 May 2025 14:54:09 GMT\n      Server:\n      - cloudflare\n      Set-Cookie:\n      - __cf_bm=ys1VX4Q4znOtsubzjx.nHCe9_hPEK_9fLmKeIYZd9LE-1747148049-1.0.1.1-c_hjWtmrr1A3GwMaBXzfhWwaX3EZ2E5Iz_5j.KkgJ3qqPA8vdd2tTpYVL1KRgkOWgWKSUHvYx9I62zt4yf.e9GO3PWHu60ji3ZEjh81.uNc;\n        path=/; expires=Tue, 13-May-25 15:24:09 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=hnWsKFQeNuxEVe5VU69rl9nk7g.ahx0f.wEzyB.f7Kk-1747148049996-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      Transfer-Encoding:\n      - chunked\n      X-Content-Type-Options:\n      - nosniff\n      access-control-expose-headers:\n      - X-Request-ID\n      alt-svc:\n      - h3=\":443\"; ma=86400\n      cf-cache-status:\n      - DYNAMIC\n      openai-organization:\n      - user-r3e61fpak04cbaokp5buoae4\n      openai-processing-ms:\n      - '547'\n      openai-version:\n      - '2020-10-01'\n      strict-transport-security:\n      - max-age=31536000; includeSubDomains; preload\n      x-envoy-upstream-service-time:\n      - '552'\n      x-ratelimit-limit-requests:\n      - '30000'\n      x-ratelimit-limit-tokens:\n      - '150000000'\n      x-ratelimit-remaining-requests:\n      - '29999'\n      x-ratelimit-remaining-tokens:\n      - '149999993'\n      x-ratelimit-reset-requests:\n      - 2ms\n      x-ratelimit-reset-tokens:\n      - 0s\n      x-request-id:\n      - req_c3e995e7a86953713a6dc1b17e399fd5\n    status:\n      code: 200\n      message: OK\n- request:\n    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\\\":\n      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\n      two numbers.\",\"parameters\":{\"properties\":{\"a\":{\"type\":\"integer\"},\"b\":{\"type\":\"integer\"}},\"required\":[\"a\",\"b\"],\"type\":\"object\"}}}]}'\n    headers:\n      accept:\n      - application/json\n      accept-encoding:\n      - gzip, deflate\n      connection:\n      - keep-alive\n      content-length:\n      - '633'\n      content-type:\n      - application/json\n      host:\n      - api.openai.com\n      user-agent:\n      - OpenAI/Python 1.78.0\n      x-stainless-arch:\n      - arm64\n      x-stainless-async:\n      - 'false'\n      x-stainless-lang:\n      - python\n      x-stainless-os:\n      - MacOS\n      x-stainless-package-version:\n      - 1.78.0\n      x-stainless-read-timeout:\n      - '600'\n      x-stainless-retry-count:\n      - '0'\n      x-stainless-runtime:\n      - CPython\n      x-stainless-runtime-version:\n      - 3.13.3\n    method: POST\n    uri: https://api.openai.com/v1/chat/completions\n  response:\n    body:\n      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}\n\n\n        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}\n\n\n        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\":\"\n        result\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\n\n        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\":\"\n        of\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\n\n        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\":\"\n        \\\\(\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\n\n        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\":\"\n        \"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\n\n        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}\n\n\n        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}\n\n\n        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\":\"\n        \\\\\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\n\n        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}\n\n\n        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\":\"\n        \"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\n\n        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}\n\n\n        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}\n\n\n        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\":\"\n        \\\\\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\n\n        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}\n\n\n        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\":\"\n        is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\n\n        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\":\"\n        \\\\(\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\n\n        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\":\"\n        \"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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}\n\n\n        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\":\"\n        \\\\\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\n\n        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}\n\n\n        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}\n\n\n        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}}}\n\n\n        data: [DONE]\n\n\n        '\n    headers:\n      CF-RAY:\n      - 93f2fd522938eb20-SJC\n      Connection:\n      - keep-alive\n      Content-Type:\n      - text/event-stream; charset=utf-8\n      Date:\n      - Tue, 13 May 2025 14:54:10 GMT\n      Server:\n      - cloudflare\n      Set-Cookie:\n      - __cf_bm=fdLfMrUkpH5wVR0YRkrP2U10JK7jFW._HUYimqZukgg-1747148050-1.0.1.1-aQIjrwFbyIcTr_HW09RscO7okcLFLbvptmCQBFweX4SskJ3FKciprVe5ffCuvcWn03rURb.wLkcTAzQzoZIdOv6OBYcJu5vMutdjPs9t0EI;\n        path=/; expires=Tue, 13-May-25 15:24:10 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=X620Mz_MZZuz8JBE23JWZUpAD7vTnI.UtEcQjGZZPBA-1747148050599-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      Transfer-Encoding:\n      - chunked\n      X-Content-Type-Options:\n      - nosniff\n      access-control-expose-headers:\n      - X-Request-ID\n      alt-svc:\n      - h3=\":443\"; ma=86400\n      cf-cache-status:\n      - DYNAMIC\n      openai-organization:\n      - user-r3e61fpak04cbaokp5buoae4\n      openai-processing-ms:\n      - '221'\n      openai-version:\n      - '2020-10-01'\n      strict-transport-security:\n      - max-age=31536000; includeSubDomains; preload\n      x-envoy-upstream-service-time:\n      - '225'\n      x-ratelimit-limit-requests:\n      - '30000'\n      x-ratelimit-limit-tokens:\n      - '150000000'\n      x-ratelimit-remaining-requests:\n      - '29999'\n      x-ratelimit-remaining-tokens:\n      - '149999987'\n      x-ratelimit-reset-requests:\n      - 2ms\n      x-ratelimit-reset-tokens:\n      - 0s\n      x-request-id:\n      - req_51f3397f64a0302e34a4d78ea85e0585\n    status:\n      code: 200\n      message: OK\nversion: 1\n"
  },
  {
    "path": "tests/cassettes/test_tools/test_tool_use_chain_of_two_calls.yaml",
    "content": "interactions:\n- request:\n    body: '{\"messages\":[{\"role\":\"user\",\"content\":\"Can the country of Crumpet have\n      dragons? Answer with only YES or NO\"}],\"model\":\"gpt-4o-mini\",\"stream\":false,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_population\",\"description\":\"Returns\n      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\n      True if the specified population can have dragons, False otherwise\",\"parameters\":{\"properties\":{\"population\":{\"type\":\"integer\"}},\"required\":[\"population\"],\"type\":\"object\"}}}]}'\n    headers:\n      accept:\n      - application/json\n      accept-encoding:\n      - gzip, deflate\n      connection:\n      - keep-alive\n      content-length:\n      - '650'\n      content-type:\n      - application/json\n      host:\n      - api.openai.com\n      user-agent:\n      - OpenAI/Python 1.78.0\n      x-stainless-arch:\n      - arm64\n      x-stainless-async:\n      - 'false'\n      x-stainless-lang:\n      - python\n      x-stainless-os:\n      - MacOS\n      x-stainless-package-version:\n      - 1.78.0\n      x-stainless-read-timeout:\n      - '600'\n      x-stainless-retry-count:\n      - '0'\n      x-stainless-runtime:\n      - CPython\n      x-stainless-runtime-version:\n      - 3.13.3\n    method: POST\n    uri: https://api.openai.com/v1/chat/completions\n  response:\n    body:\n      string: !!binary |\n        H4sIAAAAAAAAAwAAAP//jFPBjtowEL3nK6w5kyrJ0gI5slXppWzbZbdqyyoyziS4OLZrO1sQ4t+r\n        GEjCLpWaQ2TNm/fmzYy9DwgBnkNKgK2pY5UW4fSbns1n+ee7+eLP9uPmx+N29Zu5L/fT99VCwqBh\n        qNUvZO7MesNUpQU6rk4wM0gdNqrxaDiK390kb2MPVCpH0dBK7cKhCisueZhEyTCMRmE8PrHXijO0\n        kJKfASGE7P2/8Slz3EJKosE5UqG1tERI2yRCwCjRRIBay62j0sGgA5mSDmVjXdZC9ACnlMgYFaIr\n        fPz2vXM3LCpEtlh8Hz98mKuRmd/Su+nD3Imv98+fZr16R+md9oaKWrJ2SD28jacvihECklaeK5Ta\n        1DrTSteCXhEhBKgp6wqlaxqA/RKYqqUzuyWkS7g1daXRLeEAF7RDcO381JuLwaK2VLweGJVSOW/F\n        T+zphBza5QhVaqNW9gUVCi65XWcGqfU990cfnI14C1BfbBe0UZV2mVMb9EUnyVEUugvYgfHoBDrl\n        qOjFo8ngilyWo6Pcb7+9cIyyNeYdtbt4tM656gFBr/XXbq5pH9vnsvwf+Q5gDLXDPNMGc84uO+7S\n        DDbv819p7ZC9YbBonjnDzHE0zTpyLGgtjq8G7M46rLKCyxKNNtw/HSh0Ft1MknGSRJMIgkPwFwAA\n        //8DALof6VxIBAAA\n    headers:\n      CF-RAY:\n      - 93f47072dde6f88d-IAD\n      Connection:\n      - keep-alive\n      Content-Encoding:\n      - gzip\n      Content-Type:\n      - application/json\n      Date:\n      - Tue, 13 May 2025 19:07:32 GMT\n      Server:\n      - cloudflare\n      Set-Cookie:\n      - __cf_bm=vfHkbLfwVTTGPkFT0I4U0xn5CHQZYIpOutDV4z7NRlA-1747163252-1.0.1.1-kj_JiiyNxn9AWCWisV6.pYNShKVqqT0Foicji2.ZLNaAkHm5VEwac0QjxVhCiWQs9Xp_wvkeTzrgVxmD8bkzDwTPn96U.81YERXZda3_m18;\n        path=/; expires=Tue, 13-May-25 19:37:32 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=SQgXKMy2qkeOsbwwTl62blvuirTS_TkZSvEOztbYIlI-1747163252293-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      Transfer-Encoding:\n      - chunked\n      X-Content-Type-Options:\n      - nosniff\n      access-control-expose-headers:\n      - X-Request-ID\n      alt-svc:\n      - h3=\":443\"; ma=86400\n      cf-cache-status:\n      - DYNAMIC\n      openai-organization:\n      - user-r3e61fpak04cbaokp5buoae4\n      openai-processing-ms:\n      - '574'\n      openai-version:\n      - '2020-10-01'\n      strict-transport-security:\n      - max-age=31536000; includeSubDomains; preload\n      x-envoy-upstream-service-time:\n      - '591'\n      x-ratelimit-limit-requests:\n      - '30000'\n      x-ratelimit-limit-tokens:\n      - '150000000'\n      x-ratelimit-remaining-requests:\n      - '29999'\n      x-ratelimit-remaining-tokens:\n      - '149999981'\n      x-ratelimit-reset-requests:\n      - 2ms\n      x-ratelimit-reset-tokens:\n      - 0s\n      x-request-id:\n      - req_1e7dabaf1f0dba1ec89a134d3bde8476\n    status:\n      code: 200\n      message: OK\n- request:\n    body: '{\"messages\":[{\"role\":\"user\",\"content\":\"Can the country of Crumpet have\n      dragons? Answer with only YES or NO\"},{\"role\":\"assistant\",\"tool_calls\":[{\"type\":\"function\",\"id\":\"call_TTY8UFNo7rNCaOBUNtlRSvMG\",\"function\":{\"name\":\"lookup_population\",\"arguments\":\"{\\\"country\\\":\n      \\\"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\n      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\n      True if the specified population can have dragons, False otherwise\",\"parameters\":{\"properties\":{\"population\":{\"type\":\"integer\"}},\"required\":[\"population\"],\"type\":\"object\"}}}]}'\n    headers:\n      accept:\n      - application/json\n      accept-encoding:\n      - gzip, deflate\n      connection:\n      - keep-alive\n      content-length:\n      - '906'\n      content-type:\n      - application/json\n      host:\n      - api.openai.com\n      user-agent:\n      - OpenAI/Python 1.78.0\n      x-stainless-arch:\n      - arm64\n      x-stainless-async:\n      - 'false'\n      x-stainless-lang:\n      - python\n      x-stainless-os:\n      - MacOS\n      x-stainless-package-version:\n      - 1.78.0\n      x-stainless-read-timeout:\n      - '600'\n      x-stainless-retry-count:\n      - '0'\n      x-stainless-runtime:\n      - CPython\n      x-stainless-runtime-version:\n      - 3.13.3\n    method: POST\n    uri: https://api.openai.com/v1/chat/completions\n  response:\n    body:\n      string: !!binary |\n        H4sIAAAAAAAAA4xTTYvbMBC9+1eIOcfFH2k+fNyWlEIPLaWkm+5itNLY0UaWVEkOzYb892J7YzvZ\n        FOqDEfPmvXkzIx0DQkBwyAiwLfWsMjK8W5tP39a7es+i1YZvNs9fXxb4Jf7A/R1bwaRh6KdnZP7M\n        esd0ZSR6oVUHM4vUY6Maz6fzeJYm76ctUGmOsqGVxodTHVZCiTCJkmkYzcN48creasHQQUZ+BYQQ\n        cmz/jU/F8Q9kJJqcIxU6R0uErE8iBKyWTQSoc8J5qjxMBpBp5VE11lUt5QjwWsucUSmHwt13HJ2H\n        YVEpc/p7+eMgvq92Lz9n68U9Z2n6UX9e3o/qddIH0xoqasX6IY3wPp5dFSMEFK2wK6jyLd1jzi0t\n        tXJXGoQAtWVdofKNfzg+gNGmlrTRfYAsTtI4mZ7ggnQKbp0fR0OxWNSOyrfTokpp34q343p8RU79\n        ZqQujdVP7ooKhVDCbXOL1LUNj+cenI20FqC+WC0Yqyvjc6932BaN40WnCsP1G6Fn0GtP5SieziY3\n        9HKOnop29/11Y5RtkQ/U4drRmgs9AoJR72/d3NLu+heq/B/5AWAMjUeeG4tcsMuOhzSLzev8V1o/\n        5dYwOLR7wTD3Am2zD44FrWX3ZsAdnMcqL4Qq0Ror2ocDhcmjdJkskiRaRhCcgr8AAAD//wMAmw02\n        QkYEAAA=\n    headers:\n      CF-RAY:\n      - 93f47082ba71d640-IAD\n      Connection:\n      - keep-alive\n      Content-Encoding:\n      - gzip\n      Content-Type:\n      - application/json\n      Date:\n      - Tue, 13 May 2025 19:07:35 GMT\n      Server:\n      - cloudflare\n      Set-Cookie:\n      - __cf_bm=LL6YtOWVW4fA687_GIMcuJC7CM2I.uKx1vGaNkjFTgo-1747163255-1.0.1.1-qML6IsLM49e2bg7zp0uGqn3.JTJP5KlFYfb8o3v9LzyLb.cYoFBXn5te83Wxl5kVjDiXU2vH.QTFQu953KNx87LwsMkI2ZxTvH58oZWAawg;\n        path=/; expires=Tue, 13-May-25 19:37:35 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=QOa3sx0F4_nAYKtjmx9ux7qfIsyipGZq94AL_SWd2ac-1747163255176-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      Transfer-Encoding:\n      - chunked\n      X-Content-Type-Options:\n      - nosniff\n      access-control-expose-headers:\n      - X-Request-ID\n      alt-svc:\n      - h3=\":443\"; ma=86400\n      cf-cache-status:\n      - DYNAMIC\n      openai-organization:\n      - user-r3e61fpak04cbaokp5buoae4\n      openai-processing-ms:\n      - '575'\n      openai-version:\n      - '2020-10-01'\n      strict-transport-security:\n      - max-age=31536000; includeSubDomains; preload\n      x-envoy-upstream-service-time:\n      - '587'\n      x-ratelimit-limit-requests:\n      - '30000'\n      x-ratelimit-limit-tokens:\n      - '150000000'\n      x-ratelimit-remaining-requests:\n      - '29999'\n      x-ratelimit-remaining-tokens:\n      - '149999976'\n      x-ratelimit-reset-requests:\n      - 2ms\n      x-ratelimit-reset-tokens:\n      - 0s\n      x-request-id:\n      - req_66cc3b2bbe3be82a37d29fba7672d82b\n    status:\n      code: 200\n      message: OK\n- request:\n    body: '{\"messages\":[{\"role\":\"user\",\"content\":\"Can the country of Crumpet have\n      dragons? Answer with only YES or NO\"},{\"role\":\"assistant\",\"tool_calls\":[{\"type\":\"function\",\"id\":\"call_TTY8UFNo7rNCaOBUNtlRSvMG\",\"function\":{\"name\":\"lookup_population\",\"arguments\":\"{\\\"country\\\":\n      \\\"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\\\":\n      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\n      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\n      True if the specified population can have dragons, False otherwise\",\"parameters\":{\"properties\":{\"population\":{\"type\":\"integer\"}},\"required\":[\"population\"],\"type\":\"object\"}}}]}'\n    headers:\n      accept:\n      - application/json\n      accept-encoding:\n      - gzip, deflate\n      connection:\n      - keep-alive\n      content-length:\n      - '1157'\n      content-type:\n      - application/json\n      host:\n      - api.openai.com\n      user-agent:\n      - OpenAI/Python 1.78.0\n      x-stainless-arch:\n      - arm64\n      x-stainless-async:\n      - 'false'\n      x-stainless-lang:\n      - python\n      x-stainless-os:\n      - MacOS\n      x-stainless-package-version:\n      - 1.78.0\n      x-stainless-read-timeout:\n      - '600'\n      x-stainless-retry-count:\n      - '0'\n      x-stainless-runtime:\n      - CPython\n      x-stainless-runtime-version:\n      - 3.13.3\n    method: POST\n    uri: https://api.openai.com/v1/chat/completions\n  response:\n    body:\n      string: !!binary |\n        H4sIAAAAAAAAAwAAAP//jJJBb9swDIXv/hUCz/HgOGmd5NYW2447bNjQDIWhSLSjThYFiS42FPnv\n        g+w0drcO2EUHfXzUexSfMyHAaNgJUEfJqvM2v/3mP37Z31ebq69xb/zdp+Jw8/Sh2lf8qG9gkRR0\n        eETFL6p3ijpvkQ25EauAkjF1XVbranm9Kq+qAXSk0SZZ6zlfU94ZZ/KyKNd5UeXLzVl9JKMwwk58\n        z4QQ4nk4k0+n8SfsRLF4uekwRtki7C5FQkAgm25AxmgiS8ewmKAix+gG6/fvP89JwKaPMrlzvbUz\n        IJ0jlind4OnhTE4XF5ZaH+gQ/5BCY5yJxzqgjOTSi5HJw0BPmRAPQ9r+VQDwgTrPNdMPHJ5brq/H\n        fjANeaKrM2Niaeei7eKNdrVGlsbG2bhASXVEPUmn2cpeG5qBbBb6bzNv9R6DG9f+T/sJKIWeUdc+\n        oDbqdeCpLGBawX+VXYY8GIaI4ckorNlgSB+hsZG9HRcD4q/I2NWNcS0GH8y4HY2vi9W23JRlsS0g\n        O2W/AQAA//8DAFbEZUIrAwAA\n    headers:\n      CF-RAY:\n      - 93f47096cf15d6e9-IAD\n      Connection:\n      - keep-alive\n      Content-Encoding:\n      - gzip\n      Content-Type:\n      - application/json\n      Date:\n      - Tue, 13 May 2025 19:07:37 GMT\n      Server:\n      - cloudflare\n      Set-Cookie:\n      - __cf_bm=EDR.bZeRmrWVNTWef5aAJ2C5NT7yIBHq_6NzNGXNlX0-1747163257-1.0.1.1-YuS4Hj.Ncp4eOrYNT5L7AncdqT5Xn8a2DTxCka1HKKBGKdT8k70yvNTA3wMlQyVPxGD3HSCysY0a1n1zCkNs._TQe9hWOuoIDG9LtD9MBr4;\n        path=/; expires=Tue, 13-May-25 19:37:37 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=3Xqq8l5nvU4mfyEz4.llgkHC3jY.IBLFTJrD76P7UsY-1747163257692-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      Transfer-Encoding:\n      - chunked\n      X-Content-Type-Options:\n      - nosniff\n      access-control-expose-headers:\n      - X-Request-ID\n      alt-svc:\n      - h3=\":443\"; ma=86400\n      cf-cache-status:\n      - DYNAMIC\n      openai-organization:\n      - user-r3e61fpak04cbaokp5buoae4\n      openai-processing-ms:\n      - '222'\n      openai-version:\n      - '2020-10-01'\n      strict-transport-security:\n      - max-age=31536000; includeSubDomains; preload\n      x-envoy-upstream-service-time:\n      - '227'\n      x-ratelimit-limit-requests:\n      - '30000'\n      x-ratelimit-limit-tokens:\n      - '150000000'\n      x-ratelimit-remaining-requests:\n      - '29999'\n      x-ratelimit-remaining-tokens:\n      - '149999974'\n      x-ratelimit-reset-requests:\n      - 2ms\n      x-ratelimit-reset-tokens:\n      - 0s\n      x-request-id:\n      - req_d157a5a0f4b64776bc387ccab624e664\n    status:\n      code: 200\n      message: OK\nversion: 1\n"
  },
  {
    "path": "tests/cassettes/test_tools_streaming/test_tools_streaming_variant_a.yaml",
    "content": "interactions:\n- request:\n    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\n      the installed version of llm\",\"parameters\":{\"properties\":{},\"type\":\"object\"}}}]}'\n    headers:\n      accept:\n      - application/json\n      accept-encoding:\n      - gzip, deflate\n      connection:\n      - keep-alive\n      content-length:\n      - '315'\n      content-type:\n      - application/json\n      host:\n      - api.openai.com\n      user-agent:\n      - OpenAI/Python 1.78.0\n    method: POST\n    uri: https://api.openai.com/v1/chat/completions\n  response:\n    body:\n      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\":\"\"}\n\n\n        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\":\"\"}\n\n\n        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\":\"\"}\n\n\n        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\":\"\"}\n\n\n        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}}}\n\n\n        data: [DONE]\n\n\n        '\n    headers:\n      Connection:\n      - keep-alive\n      Content-Type:\n      - text/event-stream; charset=utf-8\n      Date:\n      - Tue, 23 Jul 2025 14:54:09 GMT\n      Server:\n      - cloudflare\n      Transfer-Encoding:\n      - chunked\n    status:\n      code: 200\n      message: OK\n- request:\n    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\n      the installed version of llm\",\"parameters\":{\"properties\":{},\"type\":\"object\"}}}]}'\n    headers:\n      accept:\n      - application/json\n      accept-encoding:\n      - gzip, deflate\n      connection:\n      - keep-alive\n      content-length:\n      - '517'\n      content-type:\n      - application/json\n      host:\n      - api.openai.com\n      user-agent:\n      - OpenAI/Python 1.78.0\n    method: POST\n    uri: https://api.openai.com/v1/chat/completions\n  response:\n    body:\n      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\"}\n\n\n        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\"}\n\n\n        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\":\"\n        current\"},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}],\"system_fingerprint\":\"fpv0_170758dd\"}\n\n\n        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\":\"\n        version\"},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}],\"system_fingerprint\":\"fpv0_170758dd\"}\n\n\n        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\":\"\n        of\"},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}],\"system_fingerprint\":\"fpv0_170758dd\"}\n\n\n        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\":\"\n        *\"},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}],\"system_fingerprint\":\"fpv0_170758dd\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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\":\"\n        is\"},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}],\"system_fingerprint\":\"fpv0_170758dd\"}\n\n\n        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\":\"\n        **\"},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}],\"system_fingerprint\":\"fpv0_170758dd\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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}}}\n\n\n        data: [DONE]\n\n\n        '\n    headers:\n      Connection:\n      - keep-alive\n      Content-Type:\n      - text/event-stream; charset=utf-8\n      Date:\n      - Tue, 23 Jul 2025 14:54:10 GMT\n      Server:\n      - cloudflare\n      Transfer-Encoding:\n      - chunked\n    status:\n      code: 200\n      message: OK\nversion: 1\n"
  },
  {
    "path": "tests/cassettes/test_tools_streaming/test_tools_streaming_variant_b.yaml",
    "content": "interactions:\n- request:\n    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\n      the installed version of llm\",\"parameters\":{\"properties\":{},\"type\":\"object\"}}}]}'\n    headers:\n      accept:\n      - application/json\n      accept-encoding:\n      - gzip, deflate\n      connection:\n      - keep-alive\n      content-length:\n      - '315'\n      content-type:\n      - application/json\n      host:\n      - api.openai.com\n      user-agent:\n      - OpenAI/Python 1.78.0\n    method: POST\n    uri: https://api.openai.com/v1/chat/completions\n  response:\n    body:\n      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\":\"\"}\n\n\n        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\":\"\"}\n\n\n        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\":\"\"}\n\n\n        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}}}\n\n\n        data: [DONE]\n\n\n        '\n    headers:\n      Connection:\n      - keep-alive\n      Content-Type:\n      - text/event-stream; charset=utf-8\n      Date:\n      - Tue, 23 Jul 2025 14:54:09 GMT\n      Server:\n      - cloudflare\n      Transfer-Encoding:\n      - chunked\n    status:\n      code: 200\n      message: OK\n- request:\n    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\n      the installed version of llm\",\"parameters\":{\"properties\":{},\"type\":\"object\"}}}]}'\n    headers:\n      accept:\n      - application/json\n      accept-encoding:\n      - gzip, deflate\n      connection:\n      - keep-alive\n      content-length:\n      - '517'\n      content-type:\n      - application/json\n      host:\n      - api.openai.com\n      user-agent:\n      - OpenAI/Python 1.78.0\n    method: POST\n    uri: https://api.openai.com/v1/chat/completions\n  response:\n    body:\n      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\"}\n\n\n        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\"}\n\n\n        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\":\"\n        current\"},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}],\"system_fingerprint\":\"fpv0_170758dd\"}\n\n\n        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\":\"\n        version\"},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}],\"system_fingerprint\":\"fpv0_170758dd\"}\n\n\n        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\":\"\n        of\"},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}],\"system_fingerprint\":\"fpv0_170758dd\"}\n\n\n        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\":\"\n        *\"},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}],\"system_fingerprint\":\"fpv0_170758dd\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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\":\"\n        is\"},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}],\"system_fingerprint\":\"fpv0_170758dd\"}\n\n\n        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\":\"\n        **\"},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}],\"system_fingerprint\":\"fpv0_170758dd\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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}}}\n\n\n        data: [DONE]\n\n\n        '\n    headers:\n      Connection:\n      - keep-alive\n      Content-Type:\n      - text/event-stream; charset=utf-8\n      Date:\n      - Tue, 23 Jul 2025 14:54:10 GMT\n      Server:\n      - cloudflare\n      Transfer-Encoding:\n      - chunked\n    status:\n      code: 200\n      message: OK\nversion: 1\n"
  },
  {
    "path": "tests/cassettes/test_tools_streaming/test_tools_streaming_variant_c.yaml",
    "content": "interactions:\n- request:\n    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\n      the installed version of llm\",\"parameters\":{\"properties\":{},\"type\":\"object\"}}}]}'\n    headers:\n      accept:\n      - application/json\n      accept-encoding:\n      - gzip, deflate\n      connection:\n      - keep-alive\n      content-length:\n      - '315'\n      content-type:\n      - application/json\n      host:\n      - api.openai.com\n      user-agent:\n      - OpenAI/Python 1.78.0\n    method: POST\n    uri: https://api.openai.com/v1/chat/completions\n  response:\n    body:\n      string: '\n        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\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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\"}\n\n\n        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}}}\n\n\n        data: [DONE]\n\n\n        '\n    headers:\n      Connection:\n      - keep-alive\n      Content-Type:\n      - text/event-stream; charset=utf-8\n      Date:\n      - Tue, 23 Jul 2025 14:54:09 GMT\n      Server:\n      - cloudflare\n      Transfer-Encoding:\n      - chunked\n    status:\n      code: 200\n      message: OK\n- request:\n    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\n      the installed version of llm\",\"parameters\":{\"properties\":{},\"type\":\"object\"}}}]}'\n    headers:\n      accept:\n      - application/json\n      accept-encoding:\n      - gzip, deflate\n      connection:\n      - keep-alive\n      content-length:\n      - '517'\n      content-type:\n      - application/json\n      host:\n      - api.openai.com\n      user-agent:\n      - OpenAI/Python 1.78.0\n    method: POST\n    uri: https://api.openai.com/v1/chat/completions\n  response:\n    body:\n      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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}]}\n\n\n        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}}}\n\n\n        data: [DONE]\n\n\n        '\n    headers:\n      Connection:\n      - keep-alive\n      Content-Type:\n      - text/event-stream; charset=utf-8\n      Date:\n      - Tue, 23 Jul 2025 14:54:10 GMT\n      Server:\n      - cloudflare\n      Transfer-Encoding:\n      - chunked\n    status:\n      code: 200\n      message: OK\nversion: 1\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "import pytest\nimport sqlite_utils\nimport json\nimport llm\nimport llm_echo\nfrom llm.plugins import pm\nfrom pydantic import Field\nfrom pytest_httpx import IteratorStream\nfrom typing import Optional\n\n\ndef pytest_configure(config):\n    import sys\n\n    sys._called_from_test = True\n\n\n@pytest.fixture\ndef user_path(tmpdir):\n    dir = tmpdir / \"llm.datasette.io\"\n    dir.mkdir()\n    return dir\n\n\n@pytest.fixture\ndef logs_db(user_path):\n    return sqlite_utils.Database(str(user_path / \"logs.db\"))\n\n\n@pytest.fixture\ndef user_path_with_embeddings(user_path):\n    path = str(user_path / \"embeddings.db\")\n    db = sqlite_utils.Database(path)\n    collection = llm.Collection(\"demo\", db, model_id=\"embed-demo\")\n    collection.embed(\"1\", \"hello world\", store=True)\n    collection.embed(\"2\", \"goodbye world\", store=True)\n\n\n@pytest.fixture\ndef templates_path(user_path):\n    dir = user_path / \"templates\"\n    dir.mkdir()\n    return dir\n\n\n@pytest.fixture(autouse=True)\ndef env_setup(monkeypatch, user_path):\n    monkeypatch.setenv(\"LLM_USER_PATH\", str(user_path))\n\n\nclass MockModel(llm.Model):\n    model_id = \"mock\"\n    attachment_types = {\"image/png\", \"audio/wav\"}\n    supports_schema = True\n    supports_tools = True\n\n    class Options(llm.Options):\n        max_tokens: Optional[int] = Field(\n            description=\"Maximum number of tokens to generate.\", default=None\n        )\n\n    def __init__(self):\n        self.history = []\n        self._queue = []\n        self.resolved_model_name = None\n\n    def enqueue(self, messages):\n        assert isinstance(messages, list)\n        self._queue.append(messages)\n\n    def execute(self, prompt, stream, response, conversation):\n        self.history.append((prompt, stream, response, conversation))\n        gathered = []\n        while True:\n            try:\n                messages = self._queue.pop(0)\n                for message in messages:\n                    gathered.append(message)\n                    yield message\n                break\n            except IndexError:\n                break\n        response.set_usage(\n            input=len((prompt.prompt or \"\").split()), output=len(gathered)\n        )\n        if self.resolved_model_name is not None:\n            response.set_resolved_model(self.resolved_model_name)\n\n\nclass MockKeyModel(llm.KeyModel):\n    model_id = \"mock_key\"\n    needs_key = \"mock\"\n\n    def execute(self, prompt, stream, response, conversation, key):\n        return [f\"key: {key}\"]\n\n\nclass MockAsyncKeyModel(llm.AsyncKeyModel):\n    model_id = \"mock_key\"\n    needs_key = \"mock\"\n\n    async def execute(self, prompt, stream, response, conversation, key):\n        yield f\"async, key: {key}\"\n\n\nclass AsyncMockModel(llm.AsyncModel):\n    model_id = \"mock\"\n    supports_schema = True\n\n    def __init__(self):\n        self.history = []\n        self._queue = []\n        self.resolved_model_name = None\n\n    def enqueue(self, messages):\n        assert isinstance(messages, list)\n        self._queue.append(messages)\n\n    async def execute(self, prompt, stream, response, conversation):\n        self.history.append((prompt, stream, response, conversation))\n        gathered = []\n        while True:\n            try:\n                messages = self._queue.pop(0)\n                for message in messages:\n                    gathered.append(message)\n                    yield message\n                break\n            except IndexError:\n                break\n        response.set_usage(\n            input=len((prompt.prompt or \"\").split()), output=len(gathered)\n        )\n        if self.resolved_model_name is not None:\n            response.set_resolved_model(self.resolved_model_name)\n\n\nclass EmbedDemo(llm.EmbeddingModel):\n    model_id = \"embed-demo\"\n    batch_size = 10\n    supports_binary = True\n\n    def __init__(self):\n        self.embedded_content = []\n\n    def embed_batch(self, texts):\n        if not hasattr(self, \"batch_count\"):\n            self.batch_count = 0\n        self.batch_count += 1\n        for text in texts:\n            self.embedded_content.append(text)\n            words = text.split()[:16]\n            embedding = [len(word) for word in words]\n            # Pad with 0 up to 16 words\n            embedding += [0] * (16 - len(embedding))\n            yield embedding\n\n\nclass EmbedBinaryOnly(EmbedDemo):\n    model_id = \"embed-binary-only\"\n    supports_text = False\n    supports_binary = True\n\n\nclass EmbedTextOnly(EmbedDemo):\n    model_id = \"embed-text-only\"\n    supports_text = True\n    supports_binary = False\n\n\n@pytest.fixture\ndef embed_demo():\n    return EmbedDemo()\n\n\n@pytest.fixture\ndef mock_model():\n    return MockModel()\n\n\n@pytest.fixture\ndef async_mock_model():\n    return AsyncMockModel()\n\n\n@pytest.fixture\ndef mock_key_model():\n    return MockKeyModel()\n\n\n@pytest.fixture\ndef mock_async_key_model():\n    return MockAsyncKeyModel()\n\n\n@pytest.fixture(autouse=True)\ndef register_embed_demo_model(embed_demo, mock_model, async_mock_model):\n    class MockModelsPlugin:\n        __name__ = \"MockModelsPlugin\"\n\n        @llm.hookimpl\n        def register_embedding_models(self, register):\n            register(embed_demo)\n            register(EmbedBinaryOnly())\n            register(EmbedTextOnly())\n\n        @llm.hookimpl\n        def register_models(self, register):\n            register(mock_model, async_model=async_mock_model)\n\n    pm.register(MockModelsPlugin(), name=\"undo-mock-models-plugin\")\n    try:\n        yield\n    finally:\n        pm.unregister(name=\"undo-mock-models-plugin\")\n\n\n@pytest.fixture(autouse=True)\ndef register_echo_model():\n    class EchoModelPlugin:\n        __name__ = \"EchoModelPlugin\"\n\n        @llm.hookimpl\n        def register_models(self, register):\n            register(llm_echo.Echo(), llm_echo.EchoAsync())\n\n    pm.register(EchoModelPlugin(), name=\"undo-EchoModelPlugin\")\n    try:\n        yield\n    finally:\n        pm.unregister(name=\"undo-EchoModelPlugin\")\n\n\n@pytest.fixture\ndef mocked_openai_chat(httpx_mock):\n    httpx_mock.add_response(\n        method=\"POST\",\n        url=\"https://api.openai.com/v1/chat/completions\",\n        json={\n            \"model\": \"gpt-4o-mini\",\n            \"usage\": {},\n            \"choices\": [{\"message\": {\"content\": \"Bob, Alice, Eve\"}}],\n        },\n        headers={\"Content-Type\": \"application/json\"},\n    )\n    return httpx_mock\n\n\n@pytest.fixture\ndef mocked_openai_chat_returning_fenced_code(httpx_mock):\n    httpx_mock.add_response(\n        method=\"POST\",\n        url=\"https://api.openai.com/v1/chat/completions\",\n        json={\n            \"model\": \"gpt-4o-mini\",\n            \"usage\": {},\n            \"choices\": [\n                {\n                    \"message\": {\n                        \"content\": \"Code:\\n\\n````javascript\\nfunction foo() {\\n  return 'bar';\\n}\\n````\\nDone.\",\n                    }\n                }\n            ],\n        },\n        headers={\"Content-Type\": \"application/json\"},\n    )\n    return httpx_mock\n\n\ndef stream_events():\n    for delta, finish_reason in (\n        ({\"role\": \"assistant\", \"content\": \"\"}, None),\n        ({\"content\": \"Hi\"}, None),\n        ({\"content\": \".\"}, None),\n        ({}, \"stop\"),\n    ):\n        yield \"data: {}\\n\\n\".format(\n            json.dumps(\n                {\n                    \"id\": \"chat-1\",\n                    \"object\": \"chat.completion.chunk\",\n                    \"created\": 1695096940,\n                    \"model\": \"gpt-3.5-turbo-0613\",\n                    \"choices\": [\n                        {\"index\": 0, \"delta\": delta, \"finish_reason\": finish_reason}\n                    ],\n                }\n            )\n        ).encode(\"utf-8\")\n    yield \"data: [DONE]\\n\\n\".encode(\"utf-8\")\n\n\n@pytest.fixture\ndef mocked_openai_chat_stream(httpx_mock):\n    httpx_mock.add_response(\n        method=\"POST\",\n        url=\"https://api.openai.com/v1/chat/completions\",\n        stream=IteratorStream(stream_events()),\n        headers={\"Content-Type\": \"text/event-stream\"},\n    )\n\n\n@pytest.fixture\ndef mocked_openai_completion(httpx_mock):\n    httpx_mock.add_response(\n        method=\"POST\",\n        url=\"https://api.openai.com/v1/completions\",\n        json={\n            \"id\": \"cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7\",\n            \"object\": \"text_completion\",\n            \"created\": 1589478378,\n            \"model\": \"gpt-3.5-turbo-instruct\",\n            \"choices\": [\n                {\n                    \"text\": \"\\n\\nThis is indeed a test\",\n                    \"index\": 0,\n                    \"logprobs\": None,\n                    \"finish_reason\": \"length\",\n                }\n            ],\n            \"usage\": {\"prompt_tokens\": 5, \"completion_tokens\": 7, \"total_tokens\": 12},\n        },\n        headers={\"Content-Type\": \"application/json\"},\n    )\n    return httpx_mock\n\n\ndef stream_completion_events():\n    choices_chunks = [\n        [\n            {\n                \"text\": \"\\n\\n\",\n                \"index\": 0,\n                \"logprobs\": {\n                    \"tokens\": [\"\\n\\n\"],\n                    \"token_logprobs\": [-0.6],\n                    \"top_logprobs\": [{\"\\n\\n\": -0.6, \"\\n\": -1.9}],\n                    \"text_offset\": [16],\n                },\n                \"finish_reason\": None,\n            }\n        ],\n        [\n            {\n                \"text\": \"Hi\",\n                \"index\": 0,\n                \"logprobs\": {\n                    \"tokens\": [\"Hi\"],\n                    \"token_logprobs\": [-1.1],\n                    \"top_logprobs\": [{\"Hi\": -1.1, \"Hello\": -0.7}],\n                    \"text_offset\": [18],\n                },\n                \"finish_reason\": None,\n            }\n        ],\n        [\n            {\n                \"text\": \".\",\n                \"index\": 0,\n                \"logprobs\": {\n                    \"tokens\": [\".\"],\n                    \"token_logprobs\": [-1.1],\n                    \"top_logprobs\": [{\".\": -1.1, \"!\": -0.9}],\n                    \"text_offset\": [20],\n                },\n                \"finish_reason\": None,\n            }\n        ],\n        [\n            {\n                \"text\": \"\",\n                \"index\": 0,\n                \"logprobs\": {\n                    \"tokens\": [],\n                    \"token_logprobs\": [],\n                    \"top_logprobs\": [],\n                    \"text_offset\": [],\n                },\n                \"finish_reason\": \"stop\",\n            }\n        ],\n    ]\n\n    for choices in choices_chunks:\n        yield \"data: {}\\n\\n\".format(\n            json.dumps(\n                {\n                    \"id\": \"cmpl-80MdSaou7NnPuff5ZyRMysWBmgSPS\",\n                    \"object\": \"text_completion\",\n                    \"created\": 1695097702,\n                    \"choices\": choices,\n                    \"model\": \"gpt-3.5-turbo-instruct\",\n                }\n            )\n        ).encode(\"utf-8\")\n    yield \"data: [DONE]\\n\\n\".encode(\"utf-8\")\n\n\n@pytest.fixture\ndef mocked_openai_completion_logprobs_stream(httpx_mock):\n    httpx_mock.add_response(\n        method=\"POST\",\n        url=\"https://api.openai.com/v1/completions\",\n        stream=IteratorStream(stream_completion_events()),\n        headers={\"Content-Type\": \"text/event-stream\"},\n    )\n    return httpx_mock\n\n\n@pytest.fixture\ndef mocked_openai_completion_logprobs(httpx_mock):\n    httpx_mock.add_response(\n        method=\"POST\",\n        url=\"https://api.openai.com/v1/completions\",\n        json={\n            \"id\": \"cmpl-80MeBfKJutM0uMNJkRrebJLeP3bxL\",\n            \"object\": \"text_completion\",\n            \"created\": 1695097747,\n            \"model\": \"gpt-3.5-turbo-instruct\",\n            \"choices\": [\n                {\n                    \"text\": \"\\n\\nHi.\",\n                    \"index\": 0,\n                    \"logprobs\": {\n                        \"tokens\": [\"\\n\\n\", \"Hi\", \"1\"],\n                        \"token_logprobs\": [-0.6, -1.1, -0.9],\n                        \"top_logprobs\": [\n                            {\"\\n\\n\": -0.6, \"\\n\": -1.9},\n                            {\"Hi\": -1.1, \"Hello\": -0.7},\n                            {\".\": -0.9, \"!\": -1.1},\n                        ],\n                        \"text_offset\": [16, 18, 20],\n                    },\n                    \"finish_reason\": \"stop\",\n                }\n            ],\n            \"usage\": {\"prompt_tokens\": 5, \"completion_tokens\": 3, \"total_tokens\": 8},\n        },\n        headers={\"Content-Type\": \"application/json\"},\n    )\n    return httpx_mock\n\n\n@pytest.fixture\ndef mocked_localai(httpx_mock):\n    httpx_mock.add_response(\n        method=\"POST\",\n        url=\"http://localai.localhost/chat/completions\",\n        json={\n            \"model\": \"orca\",\n            \"usage\": {},\n            \"choices\": [{\"message\": {\"content\": \"Bob, Alice, Eve\"}}],\n        },\n        headers={\"Content-Type\": \"application/json\"},\n    )\n    httpx_mock.add_response(\n        method=\"POST\",\n        url=\"http://localai.localhost/completions\",\n        json={\n            \"model\": \"completion-babbage\",\n            \"usage\": {},\n            \"choices\": [{\"text\": \"Hello\"}],\n        },\n        headers={\"Content-Type\": \"application/json\"},\n    )\n    return httpx_mock\n\n\n@pytest.fixture\ndef collection():\n    collection = llm.Collection(\"test\", model_id=\"embed-demo\")\n    collection.embed(1, \"hello world\")\n    collection.embed(2, \"goodbye world\")\n    return collection\n\n\n@pytest.fixture(scope=\"module\")\ndef vcr_config():\n    return {\"filter_headers\": [\"Authorization\"]}\n\n\ndef extract_braces(s):\n    first = s.find(\"{\")\n    last = s.rfind(\"}\")\n    if first != -1 and last != -1 and first < last:\n        return s[first : last + 1]\n    return None\n"
  },
  {
    "path": "tests/test-llm-load-plugins.sh",
    "content": "#!/bin/bash\n# This should only run in environments where both\n# llm-cluster and llm-mistral are installed\n\nPLUGINS=$(llm plugins)\necho \"$PLUGINS\" | jq 'any(.[]; .name == \"llm-mistral\")' | \\\n  grep -q true || ( \\\n    echo \"Test failed: llm-mistral not found\" && \\\n    exit 1 \\\n  )\n# With the LLM_LOAD_PLUGINS we should not see that\nPLUGINS2=$(LLM_LOAD_PLUGINS=llm-cluster llm plugins)\necho \"$PLUGINS2\" | jq 'any(.[]; .name == \"llm-mistral\")' | \\\n  grep -q false || ( \\\n    echo \"Test failed: llm-mistral should not have been loaded\" && \\\n    exit 1 \\\n  )\necho \"$PLUGINS2\" | jq 'any(.[]; .name == \"llm-cluster\")' | \\\n  grep -q true || ( \\\n    echo \"Test llm-cluster should have been loaded\" && \\\n    exit 1 \\\n  )\n# With LLM_LOAD_PLUGINS='' we should see no plugins\nPLUGINS3=$(LLM_LOAD_PLUGINS='' llm plugins)\necho \"$PLUGINS3\"| \\\n  grep -q '\\[\\]' || ( \\\n    echo \"Test failed: plugins should have returned []\" && \\\n    exit 1 \\\n  )\n"
  },
  {
    "path": "tests/test_aliases.py",
    "content": "from click.testing import CliRunner\nfrom llm.cli import cli\nimport llm\nimport json\nimport pytest\nimport re\n\n\n@pytest.mark.parametrize(\"model_id_or_alias\", (\"gpt-3.5-turbo\", \"chatgpt\"))\ndef test_set_alias(model_id_or_alias):\n    with pytest.raises(llm.UnknownModelError):\n        llm.get_model(\"this-is-a-new-alias\")\n    llm.set_alias(\"this-is-a-new-alias\", model_id_or_alias)\n    assert llm.get_model(\"this-is-a-new-alias\").model_id == \"gpt-3.5-turbo\"\n\n\ndef test_remove_alias():\n    with pytest.raises(KeyError):\n        llm.remove_alias(\"some-other-alias\")\n    llm.set_alias(\"some-other-alias\", \"gpt-3.5-turbo\")\n    assert llm.get_model(\"some-other-alias\").model_id == \"gpt-3.5-turbo\"\n    llm.remove_alias(\"some-other-alias\")\n    with pytest.raises(llm.UnknownModelError):\n        llm.get_model(\"some-other-alias\")\n\n\n@pytest.mark.parametrize(\"args\", ([\"aliases\", \"list\"], [\"aliases\"]))\ndef test_cli_aliases_list(args):\n    llm.set_alias(\"e-demo\", \"embed-demo\")\n    runner = CliRunner()\n    result = runner.invoke(cli, args)\n    assert result.exit_code == 0\n    for line in (\n        \"3.5         : gpt-3.5-turbo\\n\"\n        \"chatgpt     : gpt-3.5-turbo\\n\"\n        \"chatgpt-16k : gpt-3.5-turbo-16k\\n\"\n        \"3.5-16k     : gpt-3.5-turbo-16k\\n\"\n        \"4           : gpt-4\\n\"\n        \"gpt4        : gpt-4\\n\"\n        \"4-32k       : gpt-4-32k\\n\"\n        \"e-demo      : embed-demo (embedding)\\n\"\n        \"ada         : text-embedding-ada-002 (embedding)\\n\"\n    ).split(\"\\n\"):\n        line = line.strip()\n        if not line:\n            continue\n        # Turn the whitespace into a regex\n        regex = r\"\\s+\".join(re.escape(part) for part in line.split())\n        assert re.search(regex, result.output)\n\n\n@pytest.mark.parametrize(\"args\", ([\"aliases\", \"list\"], [\"aliases\"]))\ndef test_cli_aliases_list_json(args):\n    llm.set_alias(\"e-demo\", \"embed-demo\")\n    runner = CliRunner()\n    result = runner.invoke(cli, args + [\"--json\"])\n    assert result.exit_code == 0\n    assert (\n        json.loads(result.output).items()\n        >= {\n            \"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            \"ada\": \"text-embedding-ada-002\",\n            \"e-demo\": \"embed-demo\",\n        }.items()\n    )\n\n\n@pytest.mark.parametrize(\n    \"args,expected,expected_error\",\n    (\n        ([\"foo\", \"bar\"], {\"foo\": \"bar\"}, None),\n        ([\"foo\", \"-q\", \"mo\"], {\"foo\": \"mock\"}, None),\n        ([\"foo\", \"-q\", \"mog\"], None, \"No model found matching query: mog\"),\n    ),\n)\ndef test_cli_aliases_set(user_path, args, expected, expected_error):\n    # Should be not aliases.json at start\n    assert not (user_path / \"aliases.json\").exists()\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"aliases\", \"set\"] + args)\n    if not expected_error:\n        assert result.exit_code == 0\n        assert (user_path / \"aliases.json\").exists()\n        assert json.loads((user_path / \"aliases.json\").read_text(\"utf-8\")) == expected\n    else:\n        assert result.exit_code == 1\n        assert result.output.strip() == f\"Error: {expected_error}\"\n\n\ndef test_cli_aliases_path(user_path):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"aliases\", \"path\"])\n    assert result.exit_code == 0\n    assert result.output.strip() == str(user_path / \"aliases.json\")\n\n\ndef test_cli_aliases_remove(user_path):\n    (user_path / \"aliases.json\").write_text(json.dumps({\"foo\": \"bar\"}), \"utf-8\")\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"aliases\", \"remove\", \"foo\"])\n    assert result.exit_code == 0\n    assert json.loads((user_path / \"aliases.json\").read_text(\"utf-8\")) == {}\n\n\ndef test_cli_aliases_remove_invalid(user_path):\n    (user_path / \"aliases.json\").write_text(json.dumps({\"foo\": \"bar\"}), \"utf-8\")\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"aliases\", \"remove\", \"invalid\"])\n    assert result.exit_code == 1\n    assert result.output == \"Error: No such alias: invalid\\n\"\n\n\n@pytest.mark.parametrize(\"args\", ([\"models\"], [\"models\", \"list\"]))\ndef test_cli_aliases_are_registered(user_path, args):\n    (user_path / \"aliases.json\").write_text(\n        json.dumps({\"foo\": \"bar\", \"turbo\": \"gpt-3.5-turbo\"}), \"utf-8\"\n    )\n    runner = CliRunner()\n    result = runner.invoke(cli, args)\n    assert result.exit_code == 0\n    # Check for model line only, without keys, as --options is not used\n    assert \"gpt-3.5-turbo (aliases: 3.5, chatgpt, turbo)\" in result.output\n"
  },
  {
    "path": "tests/test_async.py",
    "content": "import llm\nimport pytest\n\n\n@pytest.mark.asyncio\nasync def test_async_model(async_mock_model):\n    gathered = []\n    async_mock_model.enqueue([\"hello world\"])\n    async for chunk in async_mock_model.prompt(\"hello\"):\n        gathered.append(chunk)\n    assert gathered == [\"hello world\"]\n    # Not as an iterator\n    async_mock_model.enqueue([\"hello world\"])\n    response = await async_mock_model.prompt(\"hello\")\n    text = await response.text()\n    assert text == \"hello world\"\n    assert isinstance(response, llm.AsyncResponse)\n    usage = await response.usage()\n    assert usage.input == 1\n    assert usage.output == 1\n    assert usage.details is None\n\n\n@pytest.mark.asyncio\nasync def test_async_model_conversation(async_mock_model):\n    async_mock_model.enqueue([\"joke 1\"])\n    conversation = async_mock_model.conversation()\n    response = await conversation.prompt(\"joke\")\n    text = await response.text()\n    assert text == \"joke 1\"\n    async_mock_model.enqueue([\"joke 2\"])\n    response2 = await conversation.prompt(\"again\")\n    text2 = await response2.text()\n    assert text2 == \"joke 2\"\n\n\n@pytest.mark.asyncio\nasync def test_async_on_done(async_mock_model):\n    async_mock_model.enqueue([\"hello world\"])\n    response = await async_mock_model.prompt(prompt=\"hello\")\n    caught = []\n\n    def done(response):\n        caught.append(response)\n\n    assert len(caught) == 0\n    await response.on_done(done)\n    await response.text()\n    assert response._done\n    assert len(caught) == 1\n\n\n@pytest.mark.asyncio\nasync def test_async_conversation(async_mock_model):\n    async_mock_model.enqueue([\"one\"])\n    conversation = async_mock_model.conversation()\n    response1 = await conversation.prompt(\"hi\").text()\n    async_mock_model.enqueue([\"two\"])\n    response2 = await conversation.prompt(\"hi\").text()\n    assert response1 == \"one\"\n    assert response2 == \"two\"\n"
  },
  {
    "path": "tests/test_attachments.py",
    "content": "from click.testing import CliRunner\nimport os\nimport sys\nfrom unittest.mock import ANY\nimport llm\nfrom llm import cli\nimport pytest\n\nTINY_PNG = (\n    b\"\\x89PNG\\r\\n\\x1a\\n\\x00\\x00\\x00\\rIHDR\\x00\\x00\\x00\\xa6\\x00\\x00\\x01\\x1a\"\n    b\"\\x02\\x03\\x00\\x00\\x00\\xe6\\x99\\xc4^\\x00\\x00\\x00\\tPLTE\\xff\\xff\\xff\"\n    b\"\\x00\\xff\\x00\\xfe\\x01\\x00\\x12t\\x01J\\x00\\x00\\x00GIDATx\\xda\\xed\\xd81\\x11\"\n    b\"\\x000\\x08\\xc0\\xc0.]\\xea\\xaf&Q\\x89\\x04V\\xe0>\\xf3+\\xc8\\x91Z\\xf4\\xa2\\x08EQ\\x14E\"\n    b\"Q\\x14EQ\\x14EQ\\xd4B\\x91$I3\\xbb\\xbf\\x08EQ\\x14EQ\\x14EQ\\x14E\\xd1\\xa5\"\n    b\"\\xd4\\x17\\x91\\xc6\\x95\\x05\\x15\\x0f\\x9f\\xc5\\t\\x9f\\xa4\\x00\\x00\\x00\\x00IEND\\xaeB`\"\n    b\"\\x82\"\n)\n\nTINY_WAV = b\"RIFF$\\x00\\x00\\x00WAVEfmt \\x10\\x00\\x00\\x00\\x01\\x00\\x01\\x00D\\xac\\x00\\x00\"\n\n\n@pytest.mark.parametrize(\n    \"attachment_type,attachment_content\",\n    [\n        (\"image/png\", TINY_PNG),\n        (\"audio/wav\", TINY_WAV),\n    ],\n)\ndef test_prompt_attachment(mock_model, logs_db, attachment_type, attachment_content):\n    runner = CliRunner()\n    mock_model.enqueue([\"two boxes\"])\n    result = runner.invoke(\n        cli.cli,\n        [\"prompt\", \"-m\", \"mock\", \"describe file\", \"-a\", \"-\"],\n        input=attachment_content,\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0, result.output\n    assert result.output == \"two boxes\\n\"\n    assert mock_model.history[0][0].attachments[0] == llm.Attachment(\n        type=attachment_type, path=None, url=None, content=attachment_content, _id=ANY\n    )\n\n    # Check it was logged correctly\n    conversations = list(logs_db[\"conversations\"].rows)\n    assert len(conversations) == 1\n    conversation = conversations[0]\n    assert conversation[\"model\"] == \"mock\"\n    assert conversation[\"name\"] == \"describe file\"\n    response = list(logs_db[\"responses\"].rows)[0]\n    attachment = list(logs_db[\"attachments\"].rows)[0]\n    assert attachment == {\n        \"id\": ANY,\n        \"type\": attachment_type,\n        \"path\": None,\n        \"url\": None,\n        \"content\": attachment_content,\n    }\n    prompt_attachment = list(logs_db[\"prompt_attachments\"].rows)[0]\n    assert prompt_attachment[\"attachment_id\"] == attachment[\"id\"]\n    assert prompt_attachment[\"response_id\"] == response[\"id\"]\n\n\ndef _count_open_fds():\n    \"\"\"Count open file descriptors (macOS and Linux only).\"\"\"\n    if sys.platform == \"darwin\":\n        fd_dir = \"/dev/fd\"\n    elif sys.platform == \"linux\":\n        fd_dir = \"/proc/self/fd\"\n    else:\n        return None\n    return len(os.listdir(fd_dir))\n\n\n@pytest.mark.skipif(\n    sys.platform not in (\"darwin\", \"linux\"),\n    reason=\"File descriptor counting only supported on macOS and Linux\",\n)\ndef test_attachment_no_file_descriptor_leak(tmp_path):\n    \"\"\"Verify reading attachments from paths doesn't leak file descriptors.\"\"\"\n    test_file = tmp_path / \"test.bin\"\n    test_file.write_bytes(b\"x\" * 1000)\n\n    # Warm up - first call may open other resources\n    attachment = llm.Attachment(path=str(test_file))\n    _ = attachment.id()\n    _ = attachment.content_bytes()\n\n    baseline = _count_open_fds()\n\n    # Create many attachments and read them\n    for _ in range(100):\n        a = llm.Attachment(path=str(test_file))\n        _ = a.id()\n        _ = a.content_bytes()\n\n    # File descriptor count should not have grown significantly\n    assert _count_open_fds() <= baseline + 5\n"
  },
  {
    "path": "tests/test_chat.py",
    "content": "from click.testing import CliRunner\nfrom unittest.mock import ANY\nimport json\nimport llm.cli\nimport pytest\nimport sqlite_utils\nimport sys\nimport textwrap\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\ndef test_chat_basic(mock_model, logs_db):\n    runner = CliRunner()\n    mock_model.enqueue([\"one world\"])\n    mock_model.enqueue([\"one again\"])\n    result = runner.invoke(\n        llm.cli.cli,\n        [\"chat\", \"-m\", \"mock\"],\n        input=\"Hi\\nHi two\\nquit\\n\",\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    assert result.output == (\n        \"Chatting with mock\"\n        \"\\nType 'exit' or 'quit' to exit\"\n        \"\\nType '!multi' to enter multiple lines, then '!end' to finish\"\n        \"\\nType '!edit' to open your default editor and modify the prompt\"\n        \"\\nType '!fragment <my_fragment> [<another_fragment> ...]' to insert one or more fragments\"\n        \"\\n> Hi\"\n        \"\\none world\"\n        \"\\n> Hi two\"\n        \"\\none again\"\n        \"\\n> quit\"\n        \"\\n\"\n    )\n    # Should have logged\n    conversations = list(logs_db[\"conversations\"].rows)\n    assert conversations[0] == {\n        \"id\": ANY,\n        \"name\": \"Hi\",\n        \"model\": \"mock\",\n    }\n    conversation_id = conversations[0][\"id\"]\n    responses = list(logs_db[\"responses\"].rows)\n    assert responses == [\n        {\n            \"id\": ANY,\n            \"model\": \"mock\",\n            \"resolved_model\": None,\n            \"prompt\": \"Hi\",\n            \"system\": None,\n            \"prompt_json\": None,\n            \"options_json\": \"{}\",\n            \"response\": \"one world\",\n            \"response_json\": None,\n            \"conversation_id\": conversation_id,\n            \"duration_ms\": ANY,\n            \"datetime_utc\": ANY,\n            \"input_tokens\": 1,\n            \"output_tokens\": 1,\n            \"token_details\": None,\n            \"schema_id\": None,\n        },\n        {\n            \"id\": ANY,\n            \"model\": \"mock\",\n            \"resolved_model\": None,\n            \"prompt\": \"Hi two\",\n            \"system\": None,\n            \"prompt_json\": None,\n            \"options_json\": \"{}\",\n            \"response\": \"one again\",\n            \"response_json\": None,\n            \"conversation_id\": conversation_id,\n            \"duration_ms\": ANY,\n            \"datetime_utc\": ANY,\n            \"input_tokens\": 2,\n            \"output_tokens\": 1,\n            \"token_details\": None,\n            \"schema_id\": None,\n        },\n    ]\n    # Now continue that conversation\n    mock_model.enqueue([\"continued\"])\n    result2 = runner.invoke(\n        llm.cli.cli,\n        [\"chat\", \"-m\", \"mock\", \"-c\"],\n        input=\"Continue\\nquit\\n\",\n        catch_exceptions=False,\n    )\n    assert result2.exit_code == 0\n    assert result2.output == (\n        \"Chatting with mock\"\n        \"\\nType 'exit' or 'quit' to exit\"\n        \"\\nType '!multi' to enter multiple lines, then '!end' to finish\"\n        \"\\nType '!edit' to open your default editor and modify the prompt\"\n        \"\\nType '!fragment <my_fragment> [<another_fragment> ...]' to insert one or more fragments\"\n        \"\\n> Continue\"\n        \"\\ncontinued\"\n        \"\\n> quit\"\n        \"\\n\"\n    )\n    new_responses = list(\n        logs_db.query(\n            \"select * from responses where id not in ({})\".format(\n                \", \".join(\"?\" for _ in responses)\n            ),\n            [r[\"id\"] for r in responses],\n        )\n    )\n    assert new_responses == [\n        {\n            \"id\": ANY,\n            \"model\": \"mock\",\n            \"resolved_model\": None,\n            \"prompt\": \"Continue\",\n            \"system\": None,\n            \"prompt_json\": None,\n            \"options_json\": \"{}\",\n            \"response\": \"continued\",\n            \"response_json\": None,\n            \"conversation_id\": conversation_id,\n            \"duration_ms\": ANY,\n            \"datetime_utc\": ANY,\n            \"input_tokens\": 1,\n            \"output_tokens\": 1,\n            \"token_details\": None,\n            \"schema_id\": None,\n        }\n    ]\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\ndef test_chat_system(mock_model, logs_db):\n    runner = CliRunner()\n    mock_model.enqueue([\"I am mean\"])\n    result = runner.invoke(\n        llm.cli.cli,\n        [\"chat\", \"-m\", \"mock\", \"--system\", \"You are mean\"],\n        input=\"Hi\\nquit\\n\",\n    )\n    assert result.exit_code == 0\n    assert result.output == (\n        \"Chatting with mock\"\n        \"\\nType 'exit' or 'quit' to exit\"\n        \"\\nType '!multi' to enter multiple lines, then '!end' to finish\"\n        \"\\nType '!edit' to open your default editor and modify the prompt\"\n        \"\\nType '!fragment <my_fragment> [<another_fragment> ...]' to insert one or more fragments\"\n        \"\\n> Hi\"\n        \"\\nI am mean\"\n        \"\\n> quit\"\n        \"\\n\"\n    )\n    responses = list(logs_db[\"responses\"].rows)\n    assert responses == [\n        {\n            \"id\": ANY,\n            \"model\": \"mock\",\n            \"resolved_model\": None,\n            \"prompt\": \"Hi\",\n            \"system\": \"You are mean\",\n            \"prompt_json\": None,\n            \"options_json\": \"{}\",\n            \"response\": \"I am mean\",\n            \"response_json\": None,\n            \"conversation_id\": ANY,\n            \"duration_ms\": ANY,\n            \"datetime_utc\": ANY,\n            \"input_tokens\": 1,\n            \"output_tokens\": 1,\n            \"token_details\": None,\n            \"schema_id\": None,\n        }\n    ]\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\ndef test_chat_options(mock_model, logs_db, user_path):\n    options_path = user_path / \"model_options.json\"\n    options_path.write_text(json.dumps({\"mock\": {\"max_tokens\": \"5\"}}), \"utf-8\")\n\n    runner = CliRunner()\n    mock_model.enqueue([\"Default options response\"])\n    result = runner.invoke(\n        llm.cli.cli,\n        [\"chat\", \"-m\", \"mock\"],\n        input=\"Hi\\nquit\\n\",\n    )\n    assert result.exit_code == 0\n    mock_model.enqueue([\"Override options response\"])\n    result = runner.invoke(\n        llm.cli.cli,\n        [\"chat\", \"-m\", \"mock\", \"--option\", \"max_tokens\", \"10\"],\n        input=\"Hi with override\\nquit\\n\",\n    )\n    assert result.exit_code == 0\n    responses = list(logs_db[\"responses\"].rows)\n    assert responses == [\n        {\n            \"id\": ANY,\n            \"model\": \"mock\",\n            \"resolved_model\": None,\n            \"prompt\": \"Hi\",\n            \"system\": None,\n            \"prompt_json\": None,\n            \"options_json\": '{\"max_tokens\": 5}',\n            \"response\": \"Default options response\",\n            \"response_json\": None,\n            \"conversation_id\": ANY,\n            \"duration_ms\": ANY,\n            \"datetime_utc\": ANY,\n            \"input_tokens\": 1,\n            \"output_tokens\": 1,\n            \"token_details\": None,\n            \"schema_id\": None,\n        },\n        {\n            \"id\": ANY,\n            \"model\": \"mock\",\n            \"resolved_model\": None,\n            \"prompt\": \"Hi with override\",\n            \"system\": None,\n            \"prompt_json\": None,\n            \"options_json\": '{\"max_tokens\": 10}',\n            \"response\": \"Override options response\",\n            \"response_json\": None,\n            \"conversation_id\": ANY,\n            \"duration_ms\": ANY,\n            \"datetime_utc\": ANY,\n            \"input_tokens\": 3,\n            \"output_tokens\": 1,\n            \"token_details\": None,\n            \"schema_id\": None,\n        },\n    ]\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\n@pytest.mark.parametrize(\n    \"input,expected\",\n    (\n        (\n            \"Hi\\n!multi\\nthis is multiple lines\\nuntil the !end\\n!end\\nquit\\n\",\n            [\n                {\"prompt\": \"Hi\", \"response\": \"One\\n\"},\n                {\n                    \"prompt\": \"this is multiple lines\\nuntil the !end\",\n                    \"response\": \"Two\\n\",\n                },\n            ],\n        ),\n        # quit should not work within !multi\n        (\n            \"!multi\\nthis is multiple lines\\nquit\\nuntil the !end\\n!end\\nquit\\n\",\n            [\n                {\n                    \"prompt\": \"this is multiple lines\\nquit\\nuntil the !end\",\n                    \"response\": \"One\\n\",\n                }\n            ],\n        ),\n        # Try custom delimiter\n        (\n            \"!multi abc\\nCustom delimiter\\n!end\\n!end 123\\n!end abc\\nquit\\n\",\n            [{\"prompt\": \"Custom delimiter\\n!end\\n!end 123\", \"response\": \"One\\n\"}],\n        ),\n    ),\n)\ndef test_chat_multi(mock_model, logs_db, input, expected):\n    runner = CliRunner()\n    mock_model.enqueue([\"One\\n\"])\n    mock_model.enqueue([\"Two\\n\"])\n    mock_model.enqueue([\"Three\\n\"])\n    result = runner.invoke(\n        llm.cli.cli, [\"chat\", \"-m\", \"mock\", \"--option\", \"max_tokens\", \"10\"], input=input\n    )\n    assert result.exit_code == 0\n    rows = list(logs_db[\"responses\"].rows_where(select=\"prompt, response\"))\n    assert rows == expected\n\n\n@pytest.mark.parametrize(\"custom_database_path\", (False, True))\ndef test_llm_chat_creates_log_database(tmpdir, monkeypatch, custom_database_path):\n    user_path = tmpdir / \"user\"\n    custom_db_path = tmpdir / \"custom_log.db\"\n    monkeypatch.setenv(\"LLM_USER_PATH\", str(user_path))\n    runner = CliRunner()\n    args = [\"chat\", \"-m\", \"mock\"]\n    if custom_database_path:\n        args.extend([\"--database\", str(custom_db_path)])\n    result = runner.invoke(\n        llm.cli.cli,\n        args,\n        catch_exceptions=False,\n        input=\"Hi\\nHi two\\nquit\\n\",\n    )\n    assert result.exit_code == 0\n    # Should have created user_path and put a logs.db in it\n    if custom_database_path:\n        assert custom_db_path.exists()\n        db_path = str(custom_db_path)\n    else:\n        assert (user_path / \"logs.db\").exists()\n        db_path = str(user_path / \"logs.db\")\n    assert sqlite_utils.Database(db_path)[\"responses\"].count == 2\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\ndef test_chat_tools(logs_db):\n    runner = CliRunner()\n    functions = textwrap.dedent(\"\"\"\n    def upper(text: str) -> str:\n        \"Convert text to upper case\"\n        return text.upper()                         \n    \"\"\")\n    result = runner.invoke(\n        llm.cli.cli,\n        [\"chat\", \"-m\", \"echo\", \"--functions\", functions],\n        input=\"\\n\".join(\n            [\n                json.dumps(\n                    {\n                        \"prompt\": \"Convert hello to uppercase\",\n                        \"tool_calls\": [\n                            {\"name\": \"upper\", \"arguments\": {\"text\": \"hello\"}}\n                        ],\n                    }\n                ),\n                \"quit\",\n            ]\n        ),\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    assert result.output == (\n        \"Chatting with echo\\n\"\n        \"Type 'exit' or 'quit' to exit\\n\"\n        \"Type '!multi' to enter multiple lines, then '!end' to finish\\n\"\n        \"Type '!edit' to open your default editor and modify the prompt\\n\"\n        \"Type '!fragment <my_fragment> [<another_fragment> ...]' to insert one or more fragments\\n\"\n        '> {\"prompt\": \"Convert hello to uppercase\", \"tool_calls\": [{\"name\": \"upper\", '\n        '\"arguments\": {\"text\": \"hello\"}}]}\\n'\n        \"{\\n\"\n        '  \"prompt\": \"Convert hello to uppercase\",\\n'\n        '  \"system\": \"\",\\n'\n        '  \"attachments\": [],\\n'\n        '  \"stream\": true,\\n'\n        '  \"previous\": []\\n'\n        \"}{\\n\"\n        '  \"prompt\": \"\",\\n'\n        '  \"system\": \"\",\\n'\n        '  \"attachments\": [],\\n'\n        '  \"stream\": true,\\n'\n        '  \"previous\": [\\n'\n        \"    {\\n\"\n        '      \"prompt\": \"{\\\\\"prompt\\\\\": \\\\\"Convert hello to uppercase\\\\\", '\n        '\\\\\"tool_calls\\\\\": [{\\\\\"name\\\\\": \\\\\"upper\\\\\", \\\\\"arguments\\\\\": {\\\\\"text\\\\\": '\n        '\\\\\"hello\\\\\"}}]}\"\\n'\n        \"    }\\n\"\n        \"  ],\\n\"\n        '  \"tool_results\": [\\n'\n        \"    {\\n\"\n        '      \"name\": \"upper\",\\n'\n        '      \"output\": \"HELLO\",\\n'\n        '      \"tool_call_id\": null\\n'\n        \"    }\\n\"\n        \"  ]\\n\"\n        \"}\\n\"\n        \"> quit\\n\"\n    )\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\ndef test_chat_fragments(tmpdir):\n    path1 = str(tmpdir / \"frag1.txt\")\n    path2 = str(tmpdir / \"frag2.txt\")\n    with open(path1, \"w\") as fp:\n        fp.write(\"one\")\n    with open(path2, \"w\") as fp:\n        fp.write(\"two\")\n    runner = CliRunner()\n    output = runner.invoke(\n        llm.cli.cli,\n        [\"chat\", \"-m\", \"echo\", \"-f\", path1],\n        input=(\"hi\\n!fragment {}\\nquit\\n\".format(path2)),\n    ).output\n    assert '\"prompt\": \"one' in output\n    assert '\"prompt\": \"two\"' in output\n"
  },
  {
    "path": "tests/test_chat_templates.py",
    "content": "from click.testing import CliRunner\nimport sys\nimport llm.cli\nimport pytest\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\ndef test_chat_template_system_only_no_duplicate_prompt(\n    mock_model, logs_db, templates_path\n):\n    # Template that only sets a system prompt, no user prompt\n    (templates_path / \"wild-french.yaml\").write_text(\n        \"system: Speak in French\\n\", \"utf-8\"\n    )\n\n    runner = CliRunner()\n    mock_model.enqueue([\"Bonjour !\"])\n    result = runner.invoke(\n        llm.cli.cli,\n        [\"chat\", \"-m\", \"mock\", \"-t\", \"wild-french\"],\n        input=\"hi\\nquit\\n\",\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n\n    # Ensure the logged prompt is not duplicated (no \"hi\\nhi\")\n    rows = list(logs_db[\"responses\"].rows)\n    assert len(rows) == 1\n    assert rows[0][\"prompt\"] == \"hi\"\n    assert rows[0][\"system\"] == \"Speak in French\"\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\ndef test_chat_system_fragments_only_first_turn(tmpdir, mock_model, logs_db):\n    # Create a system fragment file\n    sys_frag_path = str(tmpdir / \"sys.txt\")\n    with open(sys_frag_path, \"w\", encoding=\"utf-8\") as fp:\n        fp.write(\"System fragment content\")\n\n    runner = CliRunner()\n    # Two responses queued for two turns\n    mock_model.enqueue([\"first\"])\n    mock_model.enqueue([\"second\"])\n    result = runner.invoke(\n        llm.cli.cli,\n        [\"chat\", \"-m\", \"mock\", \"--system-fragment\", sys_frag_path],\n        input=\"Hi\\nHi two\\nquit\\n\",\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n\n    # Verify only the first response has the system fragment\n    responses = list(logs_db[\"responses\"].rows)\n    assert len(responses) == 2\n    first_id = responses[0][\"id\"]\n    second_id = responses[1][\"id\"]\n\n    sys_frags = list(logs_db[\"system_fragments\"].rows)\n    # Exactly one system fragment row, attached to the first response only\n    assert len(sys_frags) == 1\n    assert sys_frags[0][\"response_id\"] == first_id\n    assert sys_frags[0][\"response_id\"] != second_id\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\ndef test_chat_template_loads_tools_into_logs(logs_db, templates_path):\n    # Template that specifies tools; ensure chat picks them up\n    (templates_path / \"mytools.yaml\").write_text(\n        \"model: echo\\n\" \"tools:\\n\" \"- llm_version\\n\" \"- llm_time\\n\",\n        \"utf-8\",\n    )\n\n    runner = CliRunner()\n    result = runner.invoke(\n        llm.cli.cli,\n        [\"chat\", \"-t\", \"mytools\"],\n        input=\"hi\\nquit\\n\",\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n\n    # Verify a single response was logged for the conversation\n    responses = list(logs_db[\"responses\"].rows)\n    assert len(responses) == 1\n    assert responses[0][\"prompt\"] == \"hi\"\n    response_id = responses[0][\"id\"]\n\n    # Tools from the template should be recorded against that response\n    rows = list(\n        logs_db.query(\n            \"\"\"\n            select tools.name from tools\n            join tool_responses tr on tr.tool_id = tools.id\n            where tr.response_id = ?\n            order by tools.name\n            \"\"\",\n            [response_id],\n        )\n    )\n    assert [r[\"name\"] for r in rows] == [\"llm_time\", \"llm_version\"]\n"
  },
  {
    "path": "tests/test_cli_openai_models.py",
    "content": "from click.testing import CliRunner\nfrom llm.cli import cli\nimport pytest\nimport sqlite_utils\n\n\n@pytest.fixture\ndef mocked_models(httpx_mock):\n    httpx_mock.add_response(\n        method=\"GET\",\n        url=\"https://api.openai.com/v1/models\",\n        json={\n            \"data\": [\n                {\n                    \"id\": \"ada:2020-05-03\",\n                    \"object\": \"model\",\n                    \"created\": 1588537600,\n                    \"owned_by\": \"openai\",\n                },\n                {\n                    \"id\": \"babbage:2020-05-03\",\n                    \"object\": \"model\",\n                    \"created\": 1588537600,\n                    \"owned_by\": \"openai\",\n                },\n            ]\n        },\n        headers={\"Content-Type\": \"application/json\"},\n    )\n    return httpx_mock\n\n\ndef test_openai_models(mocked_models):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"openai\", \"models\", \"--key\", \"x\"])\n    assert result.exit_code == 0\n    assert result.output == (\n        \"id                    owned_by    created                  \\n\"\n        \"ada:2020-05-03        openai      2020-05-03T20:26:40+00:00\\n\"\n        \"babbage:2020-05-03    openai      2020-05-03T20:26:40+00:00\\n\"\n    )\n\n\ndef test_openai_options_min_max():\n    options = {\n        \"temperature\": [0, 2],\n        \"top_p\": [0, 1],\n        \"frequency_penalty\": [-2, 2],\n        \"presence_penalty\": [-2, 2],\n    }\n    runner = CliRunner()\n\n    for option, [min_val, max_val] in options.items():\n        result = runner.invoke(cli, [\"-m\", \"chatgpt\", \"-o\", option, \"-10\"])\n        assert result.exit_code == 1\n        assert f\"greater than or equal to {min_val}\" in result.output\n        result2 = runner.invoke(cli, [\"-m\", \"chatgpt\", \"-o\", option, \"10\"])\n        assert result2.exit_code == 1\n        assert f\"less than or equal to {max_val}\" in result2.output\n\n\n@pytest.mark.parametrize(\"model\", (\"gpt-4o-mini\", \"gpt-4o-audio-preview\"))\n@pytest.mark.parametrize(\"filetype\", (\"mp3\", \"wav\"))\ndef test_only_gpt4_audio_preview_allows_mp3_or_wav(httpx_mock, model, filetype):\n    httpx_mock.add_response(\n        method=\"HEAD\",\n        url=f\"https://www.example.com/example.{filetype}\",\n        content=b\"binary-data\",\n        headers={\"Content-Type\": \"audio/mpeg\" if filetype == \"mp3\" else \"audio/wav\"},\n    )\n    if model == \"gpt-4o-audio-preview\":\n        httpx_mock.add_response(\n            method=\"POST\",\n            # chat completion request\n            url=\"https://api.openai.com/v1/chat/completions\",\n            json={\n                \"id\": \"chatcmpl-AQT9a30kxEaM1bqxRPepQsPlCyGJh\",\n                \"object\": \"chat.completion\",\n                \"created\": 1730871958,\n                \"model\": \"gpt-4o-audio-preview-2024-10-01\",\n                \"choices\": [\n                    {\n                        \"index\": 0,\n                        \"message\": {\n                            \"role\": \"assistant\",\n                            \"content\": \"Why did the pelican get kicked out of the restaurant?\\n\\nBecause he had a big bill and no way to pay it!\",\n                            \"refusal\": None,\n                        },\n                        \"finish_reason\": \"stop\",\n                    }\n                ],\n                \"usage\": {\n                    \"prompt_tokens\": 55,\n                    \"completion_tokens\": 25,\n                    \"total_tokens\": 80,\n                    \"prompt_tokens_details\": {\n                        \"cached_tokens\": 0,\n                        \"audio_tokens\": 44,\n                        \"text_tokens\": 11,\n                        \"image_tokens\": 0,\n                    },\n                    \"completion_tokens_details\": {\n                        \"reasoning_tokens\": 0,\n                        \"audio_tokens\": 0,\n                        \"text_tokens\": 25,\n                        \"accepted_prediction_tokens\": 0,\n                        \"rejected_prediction_tokens\": 0,\n                    },\n                },\n                \"system_fingerprint\": \"fp_49254d0e9b\",\n            },\n            headers={\"Content-Type\": \"application/json\"},\n        )\n        httpx_mock.add_response(\n            method=\"GET\",\n            url=f\"https://www.example.com/example.{filetype}\",\n            content=b\"binary-data\",\n            headers={\n                \"Content-Type\": \"audio/mpeg\" if filetype == \"mp3\" else \"audio/wav\"\n            },\n        )\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"-m\",\n            model,\n            \"-a\",\n            f\"https://www.example.com/example.{filetype}\",\n            \"--no-stream\",\n            \"--key\",\n            \"x\",\n        ],\n    )\n    if model == \"gpt-4o-audio-preview\":\n        assert result.exit_code == 0\n        assert result.output == (\n            \"Why did the pelican get kicked out of the restaurant?\\n\\n\"\n            \"Because he had a big bill and no way to pay it!\\n\"\n        )\n    else:\n        assert result.exit_code == 1\n        long = \"audio/mpeg\" if filetype == \"mp3\" else \"audio/wav\"\n        assert (\n            f\"This model does not support attachments of type '{long}'\" in result.output\n        )\n\n\n@pytest.mark.parametrize(\"async_\", (False, True))\n@pytest.mark.parametrize(\"usage\", (None, \"-u\", \"--usage\"))\ndef test_gpt4o_mini_sync_and_async(monkeypatch, tmpdir, httpx_mock, async_, usage):\n    user_path = tmpdir / \"user_dir\"\n    log_db = user_path / \"logs.db\"\n    monkeypatch.setenv(\"LLM_USER_PATH\", str(user_path))\n    assert not log_db.exists()\n    httpx_mock.add_response(\n        method=\"POST\",\n        # chat completion request\n        url=\"https://api.openai.com/v1/chat/completions\",\n        json={\n            \"id\": \"chatcmpl-AQT9a30kxEaM1bqxRPepQsPlCyGJh\",\n            \"object\": \"chat.completion\",\n            \"created\": 1730871958,\n            \"model\": \"gpt-4o-mini\",\n            \"choices\": [\n                {\n                    \"index\": 0,\n                    \"message\": {\n                        \"role\": \"assistant\",\n                        \"content\": \"Ho ho ho\",\n                        \"refusal\": None,\n                    },\n                    \"finish_reason\": \"stop\",\n                }\n            ],\n            \"usage\": {\n                \"prompt_tokens\": 1000,\n                \"completion_tokens\": 2000,\n                \"total_tokens\": 12,\n            },\n            \"system_fingerprint\": \"fp_49254d0e9b\",\n        },\n        headers={\"Content-Type\": \"application/json\"},\n    )\n    runner = CliRunner(mix_stderr=False)\n    args = [\"-m\", \"gpt-4o-mini\", \"--key\", \"x\", \"--no-stream\"]\n    if usage:\n        args.append(usage)\n    if async_:\n        args.append(\"--async\")\n    result = runner.invoke(cli, args, catch_exceptions=False)\n    assert result.exit_code == 0\n    assert result.output == \"Ho ho ho\\n\"\n    if usage:\n        assert result.stderr == \"Token usage: 1,000 input, 2,000 output\\n\"\n    # Confirm it was correctly logged\n    assert log_db.exists()\n    db = sqlite_utils.Database(str(log_db))\n    assert db[\"responses\"].count == 1\n    row = next(db[\"responses\"].rows)\n    assert row[\"response\"] == \"Ho ho ho\"\n"
  },
  {
    "path": "tests/test_cli_options.py",
    "content": "from click.testing import CliRunner\nfrom llm.cli import cli\nimport pytest\nimport json\n\n\n@pytest.mark.parametrize(\n    \"args,expected_options,expected_error\",\n    (\n        (\n            [\"gpt-4o-mini\", \"temperature\", \"0.5\"],\n            {\"gpt-4o-mini\": {\"temperature\": \"0.5\"}},\n            None,\n        ),\n        (\n            [\"gpt-4o-mini\", \"temperature\", \"invalid\"],\n            {},\n            \"Error: temperature\\n  Input should be a valid number\",\n        ),\n        (\n            [\"gpt-4o-mini\", \"not-an-option\", \"invalid\"],\n            {},\n            \"Extra inputs are not permitted\",\n        ),\n    ),\n)\ndef test_set_model_default_options(user_path, args, expected_options, expected_error):\n    path = user_path / \"model_options.json\"\n    assert not path.exists()\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"models\", \"options\", \"set\"] + args)\n    if not expected_error:\n        assert result.exit_code == 0\n        assert path.exists()\n        data = json.loads(path.read_text(\"utf-8\"))\n        assert data == expected_options\n    else:\n        assert result.exit_code == 1\n        assert expected_error in result.output\n\n\ndef test_model_options_list_and_show(user_path):\n    (user_path / \"model_options.json\").write_text(\n        json.dumps(\n            {\"gpt-4o-mini\": {\"temperature\": 0.5}, \"gpt-4o\": {\"temperature\": 0.7}}\n        ),\n        \"utf-8\",\n    )\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"models\", \"options\", \"list\"])\n    assert result.exit_code == 0\n    assert (\n        result.output\n        == \"gpt-4o-mini:\\n  temperature: 0.5\\ngpt-4o:\\n  temperature: 0.7\\n\"\n    )\n    result = runner.invoke(cli, [\"models\", \"options\", \"show\", \"gpt-4o-mini\"])\n    assert result.exit_code == 0\n    assert result.output == \"temperature: 0.5\\n\"\n\n\ndef test_model_options_clear(user_path):\n    path = user_path / \"model_options.json\"\n    path.write_text(\n        json.dumps(\n            {\n                \"gpt-4o-mini\": {\"temperature\": 0.5},\n                \"gpt-4o\": {\"temperature\": 0.7, \"top_p\": 0.9},\n            }\n        ),\n        \"utf-8\",\n    )\n    assert path.exists()\n    runner = CliRunner()\n    # Clear all for gpt-4o-mini\n    result = runner.invoke(cli, [\"models\", \"options\", \"clear\", \"gpt-4o-mini\"])\n    assert result.exit_code == 0\n    # Clear just top_p for gpt-4o\n    result2 = runner.invoke(cli, [\"models\", \"options\", \"clear\", \"gpt-4o\", \"top_p\"])\n    assert result2.exit_code == 0\n    data = json.loads(path.read_text(\"utf-8\"))\n    assert data == {\"gpt-4o\": {\"temperature\": 0.7}}\n\n\ndef test_prompt_uses_model_options(user_path):\n    path = user_path / \"model_options.json\"\n    path.write_text(\"{}\", \"utf-8\")\n    # Prompt should not use an option\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"-m\", \"echo\", \"prompt\"])\n    assert result.exit_code == 0\n    assert json.loads(result.output) == {\n        \"prompt\": \"prompt\",\n        \"system\": \"\",\n        \"attachments\": [],\n        \"stream\": True,\n        \"previous\": [],\n    }\n\n    # Now set an option\n    path.write_text(json.dumps({\"echo\": {\"example_bool\": True}}), \"utf-8\")\n\n    result2 = runner.invoke(cli, [\"-m\", \"echo\", \"prompt\"])\n    assert result2.exit_code == 0\n    assert json.loads(result2.output) == {\n        \"prompt\": \"prompt\",\n        \"system\": \"\",\n        \"attachments\": [],\n        \"stream\": True,\n        \"previous\": [],\n        \"options\": {\"example_bool\": True},\n    }\n\n    # Option can be over-ridden\n    result3 = runner.invoke(\n        cli, [\"-m\", \"echo\", \"prompt\", \"-o\", \"example_bool\", \"false\"]\n    )\n    assert result3.exit_code == 0\n    assert json.loads(result3.output) == {\n        \"prompt\": \"prompt\",\n        \"system\": \"\",\n        \"attachments\": [],\n        \"stream\": True,\n        \"previous\": [],\n        \"options\": {\"example_bool\": False},\n    }\n    # Using an alias should also pick up that option\n    aliases_path = user_path / \"aliases.json\"\n    aliases_path.write_text('{\"e\": \"echo\"}', \"utf-8\")\n    result4 = runner.invoke(cli, [\"-m\", \"e\", \"prompt\"])\n    assert result4.exit_code == 0\n    assert json.loads(result4.output) == {\n        \"prompt\": \"prompt\",\n        \"system\": \"\",\n        \"attachments\": [],\n        \"stream\": True,\n        \"previous\": [],\n        \"options\": {\"example_bool\": True},\n    }\n"
  },
  {
    "path": "tests/test_embed.py",
    "content": "import json\nimport llm\nfrom llm.embeddings import Entry\nimport pytest\nimport sqlite_utils\nfrom unittest.mock import ANY\n\n\ndef test_demo_plugin():\n    model = llm.get_embedding_model(\"embed-demo\")\n    assert model.embed(\"hello world\") == [5, 5] + [0] * 14\n\n\n@pytest.mark.parametrize(\n    \"batch_size,expected_batches\",\n    (\n        (None, 100),\n        (10, 100),\n    ),\n)\ndef test_embed_huge_list(batch_size, expected_batches):\n    model = llm.get_embedding_model(\"embed-demo\")\n    huge_list = (\"hello {}\".format(i) for i in range(1000))\n    kwargs = {}\n    if batch_size:\n        kwargs[\"batch_size\"] = batch_size\n    results = model.embed_multi(huge_list, **kwargs)\n    assert repr(type(results)) == \"<class 'generator'>\"\n    first_twos = {}\n    for result in results:\n        key = (result[0], result[1])\n        first_twos[key] = first_twos.get(key, 0) + 1\n    assert first_twos == {(5, 1): 10, (5, 2): 90, (5, 3): 900}\n    assert model.batch_count == expected_batches\n\n\ndef test_embed_store(collection):\n    collection.embed(\"3\", \"hello world again\", store=True)\n    assert collection.db[\"embeddings\"].count == 3\n    assert (\n        next(collection.db[\"embeddings\"].rows_where(\"id = ?\", [\"3\"]))[\"content\"]\n        == \"hello world again\"\n    )\n\n\ndef test_embed_metadata(collection):\n    collection.embed(\"3\", \"hello yet again\", metadata={\"foo\": \"bar\"}, store=True)\n    assert collection.db[\"embeddings\"].count == 3\n    assert json.loads(\n        next(collection.db[\"embeddings\"].rows_where(\"id = ?\", [\"3\"]))[\"metadata\"]\n    ) == {\"foo\": \"bar\"}\n    entry = collection.similar(\"hello yet again\")[0]\n    assert entry.id == \"3\"\n    assert entry.metadata == {\"foo\": \"bar\"}\n    assert entry.content == \"hello yet again\"\n\n\ndef test_collection(collection):\n    assert collection.id == 1\n    assert collection.count() == 2\n    # Check that the embeddings are there\n    rows = list(collection.db[\"embeddings\"].rows)\n    assert rows == [\n        {\n            \"collection_id\": 1,\n            \"id\": \"1\",\n            \"embedding\": llm.encode([5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),\n            \"content\": None,\n            \"content_blob\": None,\n            \"content_hash\": collection.content_hash(\"hello world\"),\n            \"metadata\": None,\n            \"updated\": ANY,\n        },\n        {\n            \"collection_id\": 1,\n            \"id\": \"2\",\n            \"embedding\": llm.encode([7, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),\n            \"content\": None,\n            \"content_blob\": None,\n            \"content_hash\": collection.content_hash(\"goodbye world\"),\n            \"metadata\": None,\n            \"updated\": ANY,\n        },\n    ]\n    assert isinstance(rows[0][\"updated\"], int) and rows[0][\"updated\"] > 0\n\n\ndef test_similar(collection):\n    results = list(collection.similar(\"hello world\"))\n    assert results == [\n        Entry(id=\"1\", score=pytest.approx(0.9999999999999999)),\n        Entry(id=\"2\", score=pytest.approx(0.9863939238321437)),\n    ]\n\n\ndef test_similar_prefixed(collection):\n    results = list(collection.similar(\"hello world\", prefix=\"2\"))\n    assert results == [\n        Entry(id=\"2\", score=pytest.approx(0.9863939238321437)),\n    ]\n\n\ndef test_similar_by_id(collection):\n    results = list(collection.similar_by_id(\"1\"))\n    assert results == [\n        Entry(id=\"2\", score=pytest.approx(0.9863939238321437)),\n    ]\n\n\n@pytest.mark.parametrize(\n    \"batch_size,expected_batches\",\n    (\n        (None, 100),\n        (5, 200),\n    ),\n)\n@pytest.mark.parametrize(\"with_metadata\", (False, True))\ndef test_embed_multi(with_metadata, batch_size, expected_batches):\n    db = sqlite_utils.Database(memory=True)\n    collection = llm.Collection(\"test\", db, model_id=\"embed-demo\")\n    model = collection.model()\n    assert getattr(model, \"batch_count\", 0) == 0\n    ids_and_texts = ((str(i), \"hello {}\".format(i)) for i in range(1000))\n    kwargs = {}\n    if batch_size is not None:\n        kwargs[\"batch_size\"] = batch_size\n    if with_metadata:\n        ids_and_texts = ((id, text, {\"meta\": id}) for id, text in ids_and_texts)\n        collection.embed_multi_with_metadata(ids_and_texts, **kwargs)\n    else:\n        # Exercise store=True here too\n        collection.embed_multi(ids_and_texts, store=True, **kwargs)\n    rows = list(db[\"embeddings\"].rows)\n    assert len(rows) == 1000\n    rows_with_metadata = [row for row in rows if row[\"metadata\"] is not None]\n    rows_with_content = [row for row in rows if row[\"content\"] is not None]\n    if with_metadata:\n        assert len(rows_with_metadata) == 1000\n        assert len(rows_with_content) == 0\n    else:\n        assert len(rows_with_metadata) == 0\n        assert len(rows_with_content) == 1000\n    # Every row should have content_hash set\n    assert all(row[\"content_hash\"] is not None for row in rows)\n    # Check batch count\n    assert collection.model().batch_count == expected_batches\n\n\ndef test_collection_delete(collection):\n    db = collection.db\n    assert db[\"embeddings\"].count == 2\n    assert db[\"collections\"].count == 1\n    collection.delete()\n    assert db[\"embeddings\"].count == 0\n    assert db[\"collections\"].count == 0\n\n\ndef test_binary_only_and_text_only_embedding_models():\n    binary_only = llm.get_embedding_model(\"embed-binary-only\")\n    text_only = llm.get_embedding_model(\"embed-text-only\")\n\n    assert binary_only.supports_binary\n    assert not binary_only.supports_text\n    assert not text_only.supports_binary\n    assert text_only.supports_text\n\n    with pytest.raises(ValueError):\n        binary_only.embed(\"hello world\")\n\n    binary_only.embed(b\"hello world\")\n\n    with pytest.raises(ValueError):\n        text_only.embed(b\"hello world\")\n\n    text_only.embed(\"hello world\")\n\n    # Try the multi versions too\n    # Have to call list() on this or the generator is not evaluated\n    with pytest.raises(ValueError):\n        list(binary_only.embed_multi([\"hello world\"]))\n\n    list(binary_only.embed_multi([b\"hello world\"]))\n\n    with pytest.raises(ValueError):\n        list(text_only.embed_multi([b\"hello world\"]))\n\n    list(text_only.embed_multi([\"hello world\"]))\n"
  },
  {
    "path": "tests/test_embed_cli.py",
    "content": "from click.testing import CliRunner\nfrom llm.cli import cli\nfrom llm import Collection\nimport json\nimport pathlib\nimport pytest\nimport sqlite_utils\nimport sys\nfrom unittest.mock import ANY\n\n\n@pytest.mark.parametrize(\n    \"format_,expected\",\n    (\n        (\"json\", \"[5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]\\n\"),\n        (\n            \"base64\",\n            (\n                \"AACgQAAAoEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n                \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\\n\"\n            ),\n        ),\n        (\n            \"hex\",\n            (\n                \"0000a0400000a04000000000000000000000000000000000000000000\"\n                \"000000000000000000000000000000000000000000000000000000000\"\n                \"00000000000000\\n\"\n            ),\n        ),\n        (\n            \"blob\",\n            (\n                b\"\\x00\\x00\\xef\\xbf\\xbd@\\x00\\x00\\xef\\xbf\\xbd@\\x00\\x00\\x00\"\n                b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\n\"\n            ).decode(\"utf-8\"),\n        ),\n    ),\n)\n@pytest.mark.parametrize(\"scenario\", (\"argument\", \"file\", \"stdin\"))\ndef test_embed_output_format(tmpdir, format_, expected, scenario):\n    runner = CliRunner()\n    args = [\"embed\", \"--format\", format_, \"-m\", \"embed-demo\"]\n    input = None\n    if scenario == \"argument\":\n        args.extend([\"-c\", \"hello world\"])\n    elif scenario == \"file\":\n        path = tmpdir / \"input.txt\"\n        path.write_text(\"hello world\", \"utf-8\")\n        args.extend([\"-i\", str(path)])\n    elif scenario == \"stdin\":\n        input = \"hello world\"\n        args.extend([\"-i\", \"-\"])\n    result = runner.invoke(cli, args, input=input)\n    assert result.exit_code == 0\n    assert result.output == expected\n\n\n@pytest.mark.parametrize(\n    \"args,expected_error\",\n    (([\"-c\", \"Content\", \"stories\"], \"Must provide both collection and id\"),),\n)\ndef test_embed_errors(args, expected_error):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"embed\"] + args)\n    assert result.exit_code == 1\n    assert expected_error in result.output\n\n\n@pytest.mark.parametrize(\n    \"metadata,metadata_error\",\n    (\n        (None, None),\n        ('{\"foo\": \"bar\"}', None),\n        ('{\"foo\": [1, 2, 3]}', None),\n        (\"[1, 2, 3]\", \"metadata must be a JSON object\"),  # Must be a dictionary\n        ('{\"foo\": \"incomplete}', \"metadata must be valid JSON\"),\n    ),\n)\ndef test_embed_store(user_path, metadata, metadata_error):\n    embeddings_db = user_path / \"embeddings.db\"\n    assert not embeddings_db.exists()\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"embed\", \"-c\", \"hello\", \"-m\", \"embed-demo\"])\n    assert result.exit_code == 0\n    # Should not have created the table\n    assert not embeddings_db.exists()\n    # Now run it to store\n    args = [\"embed\", \"-c\", \"hello\", \"-m\", \"embed-demo\", \"items\", \"1\"]\n    if metadata is not None:\n        args.extend((\"--metadata\", metadata))\n    result = runner.invoke(cli, args)\n    if metadata_error:\n        # Should have returned an error message about invalid metadata\n        assert result.exit_code == 2\n        assert metadata_error in result.output\n        return\n    # No error, should have succeeded and stored the data\n    assert result.exit_code == 0\n    assert embeddings_db.exists()\n    # Check the contents\n    db = sqlite_utils.Database(str(embeddings_db))\n    rows = list(db[\"collections\"].rows)\n    assert rows == [{\"id\": 1, \"name\": \"items\", \"model\": \"embed-demo\"}]\n    expected_metadata = None\n    if metadata and not metadata_error:\n        expected_metadata = metadata\n    rows = list(db[\"embeddings\"].rows)\n    assert rows == [\n        {\n            \"collection_id\": 1,\n            \"id\": \"1\",\n            \"embedding\": (\n                b\"\\x00\\x00\\xa0@\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n            ),\n            \"content\": None,\n            \"content_blob\": None,\n            \"content_hash\": Collection.content_hash(\"hello\"),\n            \"metadata\": expected_metadata,\n            \"updated\": ANY,\n        }\n    ]\n    # Should show up in 'llm collections list'\n    for is_json in (False, True):\n        args = [\"collections\"]\n        if is_json:\n            args.extend([\"list\", \"--json\"])\n        result2 = runner.invoke(cli, args)\n        assert result2.exit_code == 0\n        if is_json:\n            assert json.loads(result2.output) == [\n                {\"name\": \"items\", \"model\": \"embed-demo\", \"num_embeddings\": 1}\n            ]\n        else:\n            assert result2.output == \"items: embed-demo\\n  1 embedding\\n\"\n\n    # And test deleting it too\n    result = runner.invoke(cli, [\"collections\", \"delete\", \"items\"])\n    assert result.exit_code == 0\n    assert db[\"collections\"].count == 0\n    assert db[\"embeddings\"].count == 0\n\n\ndef test_embed_store_binary(user_path):\n    runner = CliRunner()\n    args = [\"embed\", \"-m\", \"embed-demo\", \"items\", \"2\", \"--binary\", \"--store\"]\n    result = runner.invoke(cli, args, input=b\"\\x00\\x01\\x02\")\n    assert result.exit_code == 0\n    db = sqlite_utils.Database(str(user_path / \"embeddings.db\"))\n    rows = list(db[\"embeddings\"].rows)\n    assert rows == [\n        {\n            \"collection_id\": 1,\n            \"id\": \"2\",\n            \"embedding\": (\n                b\"\\x00\\x00@@\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n            ),\n            \"content\": None,\n            \"content_blob\": b\"\\x00\\x01\\x02\",\n            \"content_hash\": b'\\xb9_g\\xf6\\x1e\\xbb\\x03a\\x96\"\\xd7\\x98\\xf4_\\xc2\\xd3',\n            \"metadata\": None,\n            \"updated\": ANY,\n        }\n    ]\n\n\ndef test_collection_delete_errors(user_path):\n    db = sqlite_utils.Database(str(user_path / \"embeddings.db\"))\n    collection = Collection(\"items\", db, model_id=\"embed-demo\")\n    collection.embed(\"1\", \"hello\")\n    assert db[\"collections\"].count == 1\n    assert db[\"embeddings\"].count == 1\n    runner = CliRunner()\n    result = runner.invoke(\n        cli, [\"collections\", \"delete\", \"does-not-exist\"], catch_exceptions=False\n    )\n    assert result.exit_code == 1\n    assert \"Collection does not exist\" in result.output\n    assert db[\"collections\"].count == 1\n\n\n@pytest.mark.parametrize(\n    \"args,expected_error\",\n    (\n        ([], \"Missing argument 'COLLECTION'\"),\n        ([\"badcollection\", \"-c\", \"content\"], \"Collection does not exist\"),\n        ([\"demo\", \"bad-id\"], \"ID not found in collection\"),\n    ),\n)\ndef test_similar_errors(args, expected_error, user_path_with_embeddings):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"similar\"] + args, catch_exceptions=False)\n    assert result.exit_code != 0\n    assert expected_error in result.output\n\n\ndef test_similar_by_id_cli(user_path_with_embeddings):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"similar\", \"demo\", \"1\"], catch_exceptions=False)\n    assert result.exit_code == 0\n    assert json.loads(result.output) == {\n        \"id\": \"2\",\n        \"score\": pytest.approx(0.9863939238321437),\n        \"content\": \"goodbye world\",\n        \"metadata\": None,\n    }\n\n\n@pytest.mark.parametrize(\"option\", (\"-p\", \"--plain\"))\ndef test_similar_by_id_cli_output_plain(user_path_with_embeddings, option):\n    runner = CliRunner()\n    result = runner.invoke(\n        cli, [\"similar\", \"demo\", \"1\", option], catch_exceptions=False\n    )\n    assert result.exit_code == 0\n    # Replace score with a placeholder\n    output = result.output.split(\"(\")[0] + \"(score)\" + result.output.split(\")\")[1]\n    assert output == \"2 (score)\\n\\n  goodbye world\\n\\n\"\n\n\n@pytest.mark.parametrize(\"scenario\", (\"argument\", \"file\", \"stdin\"))\ndef test_similar_by_content_cli(tmpdir, user_path_with_embeddings, scenario):\n    runner = CliRunner()\n    args = [\"similar\", \"demo\"]\n    input = None\n    if scenario == \"argument\":\n        args.extend([\"-c\", \"hello world\"])\n    elif scenario == \"file\":\n        path = tmpdir / \"content.txt\"\n        path.write_text(\"hello world\", \"utf-8\")\n        args.extend([\"-i\", str(path)])\n    elif scenario == \"stdin\":\n        input = \"hello world\"\n        args.extend([\"-i\", \"-\"])\n    result = runner.invoke(cli, args, input=input, catch_exceptions=False)\n    assert result.exit_code == 0\n    lines = [line for line in result.output.splitlines() if line.strip()]\n    assert len(lines) == 2\n    assert json.loads(lines[0]) == {\n        \"id\": \"1\",\n        \"score\": pytest.approx(0.9999999999999999),\n        \"content\": \"hello world\",\n        \"metadata\": None,\n    }\n    assert json.loads(lines[1]) == {\n        \"id\": \"2\",\n        \"score\": pytest.approx(0.9863939238321437),\n        \"content\": \"goodbye world\",\n        \"metadata\": None,\n    }\n\n\n@pytest.mark.parametrize(\n    \"prefix,expected_result\",\n    (\n        (\n            1,\n            {\n                \"id\": \"1\",\n                \"score\": pytest.approx(0.7071067811865475),\n                \"content\": \"hello world\",\n                \"metadata\": None,\n            },\n        ),\n        (\n            2,\n            {\n                \"id\": \"2\",\n                \"score\": pytest.approx(0.8137334712067349),\n                \"content\": \"goodbye world\",\n                \"metadata\": None,\n            },\n        ),\n    ),\n)\ndef test_similar_by_content_prefixed(\n    user_path_with_embeddings, prefix, expected_result\n):\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\"similar\", \"demo\", \"-c\", \"world\", \"--prefix\", prefix, \"-n\", \"1\"],\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    assert json.loads(result.output) == expected_result\n\n\n@pytest.mark.parametrize(\"use_stdin\", (False, True))\n@pytest.mark.parametrize(\"prefix\", (None, \"prefix\"))\n@pytest.mark.parametrize(\"prepend\", (None, \"search_document: \"))\n@pytest.mark.parametrize(\n    \"filename,content\",\n    (\n        (\"phrases.csv\", \"id,phrase\\n1,hello world\\n2,goodbye world\"),\n        (\"phrases.tsv\", \"id\\tphrase\\n1\\thello world\\n2\\tgoodbye world\"),\n        (\n            \"phrases.jsonl\",\n            '{\"id\": 1, \"phrase\": \"hello world\"}\\n{\"id\": 2, \"phrase\": \"goodbye world\"}',\n        ),\n        (\n            \"phrases.json\",\n            '[{\"id\": 1, \"phrase\": \"hello world\"}, {\"id\": 2, \"phrase\": \"goodbye world\"}]',\n        ),\n    ),\n)\ndef test_embed_multi_file_input(tmpdir, use_stdin, prefix, prepend, filename, content):\n    db_path = tmpdir / \"embeddings.db\"\n    args = [\"embed-multi\", \"phrases\", \"-d\", str(db_path), \"-m\", \"embed-demo\"]\n    input = None\n    if use_stdin:\n        input = content\n        args.append(\"-\")\n    else:\n        path = tmpdir / filename\n        path.write_text(content, \"utf-8\")\n        args.append(str(path))\n    if prefix:\n        args.extend((\"--prefix\", prefix))\n    if prepend:\n        args.extend((\"--prepend\", prepend))\n    # Auto-detection can't detect JSON-nl, so make that explicit\n    if filename.endswith(\".jsonl\"):\n        args.extend((\"--format\", \"nl\"))\n    runner = CliRunner()\n    result = runner.invoke(cli, args, input=input, catch_exceptions=False)\n    assert result.exit_code == 0\n    # Check that everything was embedded correctly\n    db = sqlite_utils.Database(str(db_path))\n    assert db[\"embeddings\"].count == 2\n    ids = [row[\"id\"] for row in db[\"embeddings\"].rows]\n    expected_ids = [\"1\", \"2\"]\n    if prefix:\n        expected_ids = [\"prefix1\", \"prefix2\"]\n    assert ids == expected_ids\n\n\ndef test_embed_multi_files_binary_store(tmpdir):\n    db_path = tmpdir / \"embeddings.db\"\n    args = [\"embed-multi\", \"binfiles\", \"-d\", str(db_path), \"-m\", \"embed-demo\"]\n    bin_path = tmpdir / \"file.bin\"\n    bin_path.write(b\"\\x00\\x01\\x02\")\n    args.extend((\"--files\", str(tmpdir), \"*.bin\", \"--store\", \"--binary\"))\n    runner = CliRunner()\n    result = runner.invoke(cli, args, catch_exceptions=False)\n    assert result.exit_code == 0\n    db = sqlite_utils.Database(str(db_path))\n    assert db[\"embeddings\"].count == 1\n    row = list(db[\"embeddings\"].rows)[0]\n    assert row == {\n        \"collection_id\": 1,\n        \"id\": \"file.bin\",\n        \"embedding\": (\n            b\"\\x00\\x00@@\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n            b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n            b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n            b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n        ),\n        \"content\": None,\n        \"content_blob\": b\"\\x00\\x01\\x02\",\n        \"content_hash\": b'\\xb9_g\\xf6\\x1e\\xbb\\x03a\\x96\"\\xd7\\x98\\xf4_\\xc2\\xd3',\n        \"metadata\": None,\n        \"updated\": ANY,\n    }\n\n\n@pytest.mark.parametrize(\"use_other_db\", (True, False))\n@pytest.mark.parametrize(\"prefix\", (None, \"prefix\"))\n@pytest.mark.parametrize(\"prepend\", (None, \"search_document: \"))\ndef test_embed_multi_sql(tmpdir, use_other_db, prefix, prepend):\n    db_path = str(tmpdir / \"embeddings.db\")\n    db = sqlite_utils.Database(db_path)\n    extra_args = []\n    if use_other_db:\n        db_path2 = str(tmpdir / \"other.db\")\n        db = sqlite_utils.Database(db_path2)\n        extra_args = [\"--attach\", \"other\", db_path2]\n\n    if prefix:\n        extra_args.extend((\"--prefix\", prefix))\n    if prepend:\n        extra_args.extend((\"--prepend\", prepend))\n\n    db[\"content\"].insert_all(\n        [\n            {\"id\": 1, \"name\": \"cli\", \"description\": \"Command line interface\"},\n            {\"id\": 2, \"name\": \"sql\", \"description\": \"Structured query language\"},\n        ],\n        pk=\"id\",\n    )\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"embed-multi\",\n            \"stuff\",\n            \"-d\",\n            db_path,\n            \"--sql\",\n            \"select * from content\",\n            \"-m\",\n            \"embed-demo\",\n            \"--store\",\n        ]\n        + extra_args,\n    )\n    assert result.exit_code == 0\n    embeddings_db = sqlite_utils.Database(db_path)\n    assert embeddings_db[\"embeddings\"].count == 2\n    rows = list(embeddings_db.query(\"select id, content from embeddings order by id\"))\n    assert rows == [\n        {\n            \"id\": (prefix or \"\") + \"1\",\n            \"content\": (prepend or \"\") + \"cli Command line interface\",\n        },\n        {\n            \"id\": (prefix or \"\") + \"2\",\n            \"content\": (prepend or \"\") + \"sql Structured query language\",\n        },\n    ]\n\n\ndef test_embed_multi_batch_size(embed_demo, tmpdir):\n    db_path = str(tmpdir / \"data.db\")\n    runner = CliRunner()\n    sql = \"\"\"\n    with recursive cte (id) as (\n      select 1\n      union all\n      select id+1 from cte where id < 100\n    )\n    select id, 'Row ' || cast(id as text) as value from cte\n    \"\"\"\n    assert getattr(embed_demo, \"batch_count\", 0) == 0\n    result = runner.invoke(\n        cli,\n        [\n            \"embed-multi\",\n            \"rows\",\n            \"--sql\",\n            sql,\n            \"-d\",\n            db_path,\n            \"-m\",\n            \"embed-demo\",\n            \"--store\",\n            \"--batch-size\",\n            \"8\",\n        ],\n    )\n    assert result.exit_code == 0\n    db = sqlite_utils.Database(db_path)\n    assert db[\"embeddings\"].count == 100\n    assert embed_demo.batch_count == 13\n\n\n@pytest.fixture\ndef multi_files(tmpdir):\n    db_path = str(tmpdir / \"files.db\")\n    files = tmpdir / \"files\"\n    for filename, content in (\n        (\"file1.txt\", b\"hello world\"),\n        (\"file2.txt\", b\"goodbye world\"),\n        (\"nested/one.txt\", b\"one\"),\n        (\"nested/two.txt\", b\"two\"),\n        (\"nested/more/three.txt\", b\"three\"),\n        # This tests the fallback to latin-1 encoding:\n        (\"nested/more/ignored.ini\", b\"Has weird \\x96 character\"),\n    ):\n        path = pathlib.Path(files / filename)\n        path.parent.mkdir(parents=True, exist_ok=True)\n        path.write_bytes(content)\n    return db_path, tmpdir / \"files\"\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\n@pytest.mark.parametrize(\"scenario\", (\"single\", \"multi\"))\n@pytest.mark.parametrize(\"prepend\", (None, \"search_document: \"))\ndef test_embed_multi_files(multi_files, scenario, prepend):\n    db_path, files = multi_files\n    for filename, content in (\n        (\"file1.txt\", b\"hello world\"),\n        (\"file2.txt\", b\"goodbye world\"),\n        (\"nested/one.txt\", b\"one\"),\n        (\"nested/two.txt\", b\"two\"),\n        (\"nested/more/three.txt\", b\"three\"),\n        # This tests the fallback to latin-1 encoding:\n        (\"nested/more.txt/ignored.ini\", b\"Has weird \\x96 character\"),\n    ):\n        path = pathlib.Path(files / filename)\n        path.parent.mkdir(parents=True, exist_ok=True)\n        path.write_bytes(content)\n\n    extra_args = []\n\n    if prepend:\n        extra_args.extend((\"--prepend\", prepend))\n    if scenario == \"single\":\n        extra_args.extend([\"--files\", str(files), \"**/*.txt\"])\n    else:\n        extra_args.extend(\n            [\n                \"--files\",\n                str(files / \"nested\" / \"more\"),\n                \"**/*.ini\",\n                \"--files\",\n                str(files / \"nested\"),\n                \"*.txt\",\n            ]\n        )\n\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"embed-multi\",\n            \"files\",\n            \"-d\",\n            db_path,\n            \"-m\",\n            \"embed-demo\",\n            \"--store\",\n        ]\n        + extra_args,\n    )\n    assert result.exit_code == 0\n    embeddings_db = sqlite_utils.Database(db_path)\n    rows = list(embeddings_db.query(\"select id, content from embeddings order by id\"))\n    if scenario == \"single\":\n        assert rows == [\n            {\"id\": \"file1.txt\", \"content\": (prepend or \"\") + \"hello world\"},\n            {\"id\": \"file2.txt\", \"content\": (prepend or \"\") + \"goodbye world\"},\n            {\"id\": \"nested/more/three.txt\", \"content\": (prepend or \"\") + \"three\"},\n            {\"id\": \"nested/one.txt\", \"content\": (prepend or \"\") + \"one\"},\n            {\"id\": \"nested/two.txt\", \"content\": (prepend or \"\") + \"two\"},\n        ]\n    else:\n        assert rows == [\n            {\n                \"id\": \"ignored.ini\",\n                \"content\": (prepend or \"\") + \"Has weird \\x96 character\",\n            },\n            {\"id\": \"one.txt\", \"content\": (prepend or \"\") + \"one\"},\n            {\"id\": \"two.txt\", \"content\": (prepend or \"\") + \"two\"},\n        ]\n\n\n@pytest.mark.parametrize(\n    \"args,expected_error\",\n    (([\"not-a-dir\", \"*.txt\"], \"Invalid directory: not-a-dir\"),),\n)\ndef test_embed_multi_files_errors(multi_files, args, expected_error):\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\"embed-multi\", \"files\", \"-m\", \"embed-demo\", \"--files\"] + args,\n    )\n    assert result.exit_code == 2\n    assert expected_error in result.output\n\n\n@pytest.mark.parametrize(\n    \"extra_args,expected_error\",\n    (\n        # With no args default utf-8 with latin-1 fallback should work\n        ([], None),\n        ([\"--encoding\", \"utf-8\"], \"Could not decode text in file\"),\n        ([\"--encoding\", \"latin-1\"], None),\n        ([\"--encoding\", \"latin-1\", \"--encoding\", \"utf-8\"], None),\n        ([\"--encoding\", \"utf-8\", \"--encoding\", \"latin-1\"], None),\n    ),\n)\ndef test_embed_multi_files_encoding(multi_files, extra_args, expected_error):\n    db_path, files = multi_files\n    runner = CliRunner(mix_stderr=False)\n    result = runner.invoke(\n        cli,\n        [\n            \"embed-multi\",\n            \"files\",\n            \"-d\",\n            db_path,\n            \"-m\",\n            \"embed-demo\",\n            \"--files\",\n            str(files / \"nested\" / \"more\"),\n            \"*.ini\",\n            \"--store\",\n        ]\n        + extra_args,\n    )\n    if expected_error:\n        # Should still succeed with 0, but show a warning\n        assert result.exit_code == 0\n        assert expected_error in result.stderr\n    else:\n        assert result.exit_code == 0\n        assert not result.stderr\n        embeddings_db = sqlite_utils.Database(db_path)\n        rows = list(\n            embeddings_db.query(\"select id, content from embeddings order by id\")\n        )\n        assert rows == [\n            {\"id\": \"ignored.ini\", \"content\": \"Has weird \\x96 character\"},\n        ]\n\n\ndef test_default_embedding_model():\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"embed-models\", \"default\"])\n    assert result.exit_code == 0\n    assert result.output == \"<No default embedding model set>\\n\"\n    result2 = runner.invoke(cli, [\"embed-models\", \"default\", \"ada-002\"])\n    assert result2.exit_code == 0\n    result3 = runner.invoke(cli, [\"embed-models\", \"default\"])\n    assert result3.exit_code == 0\n    assert result3.output == \"text-embedding-ada-002\\n\"\n    result4 = runner.invoke(cli, [\"embed-models\", \"default\", \"--remove-default\"])\n    assert result4.exit_code == 0\n    result5 = runner.invoke(cli, [\"embed-models\", \"default\"])\n    assert result5.exit_code == 0\n    assert result5.output == \"<No default embedding model set>\\n\"\n    # Now set the default and actually use it\n    result6 = runner.invoke(cli, [\"embed-models\", \"default\", \"embed-demo\"])\n    assert result6.exit_code == 0\n    result7 = runner.invoke(cli, [\"embed\", \"-c\", \"hello world\"])\n    assert result7.exit_code == 0\n    assert result7.output == \"[5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]\\n\"\n\n\n@pytest.mark.parametrize(\n    \"args,expected_model_id\",\n    (\n        ([\"-q\", \"text-embedding-3-large\"], \"text-embedding-3-large\"),\n        ([\"-q\", \"text\", \"-q\", \"3\"], \"text-embedding-3-large\"),\n    ),\n)\ndef test_llm_embed_models_query(user_path, args, expected_model_id):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"embed-models\"] + args, catch_exceptions=False)\n    assert result.exit_code == 0\n    assert expected_model_id in result.output\n\n\n@pytest.mark.parametrize(\"default_is_set\", (False, True))\n@pytest.mark.parametrize(\"command\", (\"embed\", \"embed-multi\"))\ndef test_default_embed_model_errors(user_path, default_is_set, command):\n    runner = CliRunner()\n    if default_is_set:\n        (user_path / \"default_embedding_model.txt\").write_text(\n            \"embed-demo\", encoding=\"utf8\"\n        )\n    args = []\n    input = None\n    if command == \"embed-multi\":\n        args = [\"embed-multi\", \"example\", \"-\"]\n        input = \"id,name\\n1,hello\"\n    else:\n        args = [\"embed\", \"example\", \"1\", \"-c\", \"hello world\"]\n    result = runner.invoke(cli, args, input=input, catch_exceptions=False)\n    if default_is_set:\n        assert result.exit_code == 0\n    else:\n        assert result.exit_code == 1\n        assert (\n            \"You need to specify an embedding model (no default model is set)\"\n            in result.output\n        )\n        # Now set the default model and try again\n        result2 = runner.invoke(cli, [\"embed-models\", \"default\", \"embed-demo\"])\n        assert result2.exit_code == 0\n        result3 = runner.invoke(cli, args, input=input, catch_exceptions=False)\n        assert result3.exit_code == 0\n    # At the end of this, there should be 2 embeddings\n    db = sqlite_utils.Database(str(user_path / \"embeddings.db\"))\n    assert db[\"embeddings\"].count == 1\n\n\ndef test_duplicate_content_embedded_only_once(embed_demo):\n    # content_hash should avoid embedding the same content twice\n    # per collection\n    db = sqlite_utils.Database(memory=True)\n    assert len(embed_demo.embedded_content) == 0\n    collection = Collection(\"test\", db, model_id=\"embed-demo\")\n    collection.embed(\"1\", \"hello world\")\n    assert len(embed_demo.embedded_content) == 1\n    collection.embed(\"2\", \"goodbye world\")\n    assert db[\"embeddings\"].count == 2\n    assert len(embed_demo.embedded_content) == 2\n    collection.embed(\"1\", \"hello world\")\n    assert db[\"embeddings\"].count == 2\n    assert len(embed_demo.embedded_content) == 2\n    # The same string in another collection should be embedded\n    c2 = Collection(\"test2\", db, model_id=\"embed-demo\")\n    c2.embed(\"1\", \"hello world\")\n    assert db[\"embeddings\"].count == 3\n    assert len(embed_demo.embedded_content) == 3\n\n    # Same again for embed_multi\n    collection.embed_multi(\n        ((\"1\", \"hello world\"), (\"2\", \"goodbye world\"), (\"3\", \"this is new\"))\n    )\n    # Should have only embedded one more thing\n    assert db[\"embeddings\"].count == 4\n    assert len(embed_demo.embedded_content) == 4\n"
  },
  {
    "path": "tests/test_encode_decode.py",
    "content": "import llm\nimport pytest\nimport numpy as np\n\n\n@pytest.mark.parametrize(\n    \"array\",\n    (\n        (0.0, 1.0, 1.5),\n        (3423.0, 222.0, -1234.5),\n    ),\n)\ndef test_roundtrip(array):\n    encoded = llm.encode(array)\n    decoded = llm.decode(encoded)\n    assert decoded == array\n    # Try with numpy as well\n    numpy_decoded = np.frombuffer(encoded, \"<f4\")\n    assert tuple(numpy_decoded.tolist()) == array\n"
  },
  {
    "path": "tests/test_fragments_cli.py",
    "content": "from click.testing import CliRunner\nfrom importlib.metadata import version\nfrom llm.cli import cli\nfrom unittest import mock\nimport os\nimport yaml\nimport sqlite_utils\nimport textwrap\n\n\ndef test_fragments_set_show_remove(user_path):\n    runner = CliRunner()\n    with runner.isolated_filesystem():\n        with open(\"fragment1.txt\", \"w\") as f:\n            f.write(\"Hello fragment 1\")\n\n        # llm fragments --aliases should return nothing\n        assert runner.invoke(cli, [\"fragments\", \"list\", \"--aliases\"]).output == \"\"\n        assert (\n            runner.invoke(cli, [\"fragments\", \"set\", \"f1\", \"fragment1.txt\"]).exit_code\n            == 0\n        )\n        result1 = runner.invoke(cli, [\"fragments\", \"show\", \"f1\"])\n        assert result1.exit_code == 0\n        assert result1.output == \"Hello fragment 1\\n\"\n\n        # Should be in the list now\n        def get_list():\n            result2 = runner.invoke(cli, [\"fragments\", \"list\"])\n            assert result2.exit_code == 0\n            return yaml.safe_load(result2.output)\n\n        # And in llm fragments --aliases\n        assert \"f1\" in runner.invoke(cli, [\"fragments\", \"list\", \"--aliases\"]).output\n\n        loaded1 = get_list()\n        assert set(loaded1[0].keys()) == {\n            \"aliases\",\n            \"content\",\n            \"datetime_utc\",\n            \"source\",\n            \"hash\",\n        }\n        assert loaded1[0][\"content\"] == \"Hello fragment 1\"\n        assert loaded1[0][\"aliases\"] == [\"f1\"]\n\n        # Show should work against both alias and hash\n        for key in (\"f1\", loaded1[0][\"hash\"]):\n            result3 = runner.invoke(cli, [\"fragments\", \"show\", key])\n            assert result3.exit_code == 0\n            assert result3.output == \"Hello fragment 1\\n\"\n\n        # But not for an invalid alias\n        result4 = runner.invoke(cli, [\"fragments\", \"show\", \"badalias\"])\n        assert result4.exit_code == 1\n        assert \"Fragment 'badalias' not found\" in result4.output\n\n        # Remove that alias\n        result5 = runner.invoke(cli, [\"fragments\", \"remove\", \"f1\"])\n        assert result5.exit_code == 0\n        # Should still be in list but no alias\n        loaded2 = get_list()\n        assert loaded2[0][\"aliases\"] == []\n        assert loaded2[0][\"content\"] == \"Hello fragment 1\"\n\n        # And --aliases list should be empty\n        assert runner.invoke(cli, [\"fragments\", \"list\", \"--aliases\"]).output == \"\"\n\n\ndef test_fragments_list(user_path):\n    runner = CliRunner()\n    with runner.isolated_filesystem():\n        # This is just to create the database schema\n        with open(\"fragment1.txt\", \"w\") as f:\n            f.write(\"1\")\n        assert (\n            runner.invoke(cli, [\"fragments\", \"set\", \"f1\", \"fragment1.txt\"]).exit_code\n            == 0\n        )\n        # Now add the rest directly to the database\n        db = sqlite_utils.Database(str(user_path / \"logs.db\"))\n        db[\"fragments\"].delete_where()\n        db[\"fragments\"].insert(\n            {\n                \"content\": \"1\",\n                \"datetime_utc\": \"2023-10-01T00:00:00Z\",\n                \"source\": \"file1.txt\",\n                \"hash\": \"hash1\",\n            },\n        )\n        db[\"fragments\"].insert(\n            {\n                \"content\": \"2\",\n                \"datetime_utc\": \"2022-10-01T00:00:00Z\",\n                \"source\": \"file2.txt\",\n                \"hash\": \"hash2\",\n            },\n        )\n        db[\"fragments\"].insert(\n            {\n                \"content\": \"3\",\n                \"datetime_utc\": \"2024-10-01T00:00:00Z\",\n                \"source\": \"file3.txt\",\n                \"hash\": \"hash3\",\n            },\n        )\n        result = runner.invoke(cli, [\"fragments\", \"list\"])\n        assert result.exit_code == 0\n        assert result.output.strip() == (textwrap.dedent(\"\"\"\n                - hash: hash2\n                  aliases: []\n                  datetime_utc: '2022-10-01T00:00:00Z'\n                  source: file2.txt\n                  content: '2'\n                - hash: hash1\n                  aliases:\n                  - f1\n                  datetime_utc: '2023-10-01T00:00:00Z'\n                  source: file1.txt\n                  content: '1'\n                - hash: hash3\n                  aliases: []\n                  datetime_utc: '2024-10-01T00:00:00Z'\n                  source: file3.txt\n                  content: '3'\n                \"\"\").strip())\n\n\n@mock.patch.dict(os.environ, {\"OPENAI_API_KEY\": \"X\"})\ndef test_fragment_url_user_agent(mocked_openai_chat, user_path):\n    mocked_openai_chat.add_response(\n        url=\"https://example.com/fragment.txt\",\n        text=\"Hello from URL\",\n    )\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"prompt\", \"-f\", \"https://example.com/fragment.txt\"])\n    assert result.exit_code == 0\n\n    # Verify the User-Agent header was sent for the fragment URL request\n    requests = mocked_openai_chat.get_requests()\n    fragment_request = [r for r in requests if \"example.com\" in str(r.url)][0]\n    llm_version = version(\"llm\")\n    expected_user_agent = f\"llm/{llm_version} (https://llm.datasette.io/)\"\n    assert fragment_request.headers[\"User-Agent\"] == expected_user_agent\n"
  },
  {
    "path": "tests/test_keys.py",
    "content": "from click.testing import CliRunner\nimport json\nfrom llm.cli import cli\nimport pathlib\nimport pytest\nimport sys\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\n@pytest.mark.parametrize(\"env\", ({}, {\"LLM_USER_PATH\": \"/tmp/llm-keys-test\"}))\ndef test_keys_in_user_path(monkeypatch, env, user_path):\n    for key, value in env.items():\n        monkeypatch.setenv(key, value)\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"keys\", \"path\"])\n    assert result.exit_code == 0\n    if env:\n        expected = env[\"LLM_USER_PATH\"] + \"/keys.json\"\n    else:\n        expected = user_path + \"/keys.json\"\n    assert result.output.strip() == expected\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\ndef test_keys_set(monkeypatch, tmpdir):\n    user_path = tmpdir / \"user/keys\"\n    monkeypatch.setenv(\"LLM_USER_PATH\", str(user_path))\n    keys_path = user_path / \"keys.json\"\n    assert not keys_path.exists()\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"keys\", \"set\", \"openai\"], input=\"foo\")\n    assert result.exit_code == 0\n    assert keys_path.exists()\n    # Should be chmod 600\n    assert oct(keys_path.stat().mode)[-3:] == \"600\"\n    content = keys_path.read_text(\"utf-8\")\n    assert json.loads(content) == {\n        \"// Note\": \"This file stores secret API credentials. Do not share!\",\n        \"openai\": \"foo\",\n    }\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\ndef test_keys_get(monkeypatch, tmpdir):\n    user_path = tmpdir / \"user/keys\"\n    monkeypatch.setenv(\"LLM_USER_PATH\", str(user_path))\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"keys\", \"set\", \"openai\"], input=\"fx\")\n    assert result.exit_code == 0\n    result2 = runner.invoke(cli, [\"keys\", \"get\", \"openai\"])\n    assert result2.exit_code == 0\n    assert result2.output.strip() == \"fx\"\n\n\n@pytest.mark.parametrize(\"args\", ([\"keys\", \"list\"], [\"keys\"]))\ndef test_keys_list(monkeypatch, tmpdir, args):\n    user_path = str(tmpdir / \"user/keys\")\n    monkeypatch.setenv(\"LLM_USER_PATH\", user_path)\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"keys\", \"set\", \"openai\"], input=\"foo\")\n    assert result.exit_code == 0\n    result2 = runner.invoke(cli, args)\n    assert result2.exit_code == 0\n    assert result2.output.strip() == \"openai\"\n\n\n@pytest.mark.httpx_mock(\n    assert_all_requests_were_expected=False, can_send_already_matched_responses=True\n)\ndef test_uses_correct_key(mocked_openai_chat, monkeypatch, tmpdir):\n    user_dir = tmpdir / \"user-dir\"\n    pathlib.Path(user_dir).mkdir()\n    keys_path = user_dir / \"keys.json\"\n    KEYS = {\n        \"openai\": \"from-keys-file\",\n        \"other\": \"other-key\",\n    }\n    keys_path.write_text(json.dumps(KEYS), \"utf-8\")\n    monkeypatch.setenv(\"LLM_USER_PATH\", str(user_dir))\n    monkeypatch.setenv(\"OPENAI_API_KEY\", \"from-env\")\n\n    def assert_key(key):\n        request = mocked_openai_chat.get_requests()[-1]\n        assert request.headers[\"Authorization\"] == \"Bearer {}\".format(key)\n\n    runner = CliRunner()\n\n    # Called without --key uses stored key\n    result = runner.invoke(cli, [\"hello\", \"--no-stream\"], catch_exceptions=False)\n    assert result.exit_code == 0\n    assert_key(\"from-keys-file\")\n\n    # Called without --key and without keys.json uses environment variable\n    keys_path.write_text(\"{}\", \"utf-8\")\n    result2 = runner.invoke(cli, [\"hello\", \"--no-stream\"], catch_exceptions=False)\n    assert result2.exit_code == 0\n    assert_key(\"from-env\")\n    keys_path.write_text(json.dumps(KEYS), \"utf-8\")\n\n    # Called with --key name-in-keys.json uses that value\n    result3 = runner.invoke(\n        cli, [\"hello\", \"--key\", \"other\", \"--no-stream\"], catch_exceptions=False\n    )\n    assert result3.exit_code == 0\n    assert_key(\"other-key\")\n\n    # Called with --key something-else uses exactly that\n    result4 = runner.invoke(\n        cli, [\"hello\", \"--key\", \"custom-key\", \"--no-stream\"], catch_exceptions=False\n    )\n    assert result4.exit_code == 0\n    assert_key(\"custom-key\")\n"
  },
  {
    "path": "tests/test_llm.py",
    "content": "from click.testing import CliRunner\nimport llm\nfrom llm.cli import cli\nfrom llm.models import Usage\nimport json\nimport os\nimport pathlib\nfrom pydantic import BaseModel\nimport pytest\nimport sqlite_utils\nfrom unittest import mock\n\n\ndef test_version():\n    runner = CliRunner()\n    with runner.isolated_filesystem():\n        result = runner.invoke(cli, [\"--version\"])\n        assert result.exit_code == 0\n        assert result.output.startswith(\"cli, version \")\n\n\n@pytest.mark.parametrize(\"custom_database_path\", (False, True))\ndef test_llm_prompt_creates_log_database(\n    mocked_openai_chat, tmpdir, monkeypatch, custom_database_path\n):\n    user_path = tmpdir / \"user\"\n    custom_db_path = tmpdir / \"custom_log.db\"\n    monkeypatch.setenv(\"LLM_USER_PATH\", str(user_path))\n    runner = CliRunner()\n    args = [\"three names \\nfor a pet pelican\", \"--no-stream\", \"--key\", \"x\"]\n    if custom_database_path:\n        args.extend([\"--database\", str(custom_db_path)])\n    result = runner.invoke(cli, args, catch_exceptions=False)\n    assert result.exit_code == 0\n    assert result.output == \"Bob, Alice, Eve\\n\"\n    # Should have created user_path and put a logs.db in it\n    if custom_database_path:\n        assert custom_db_path.exists()\n        db_path = str(custom_db_path)\n    else:\n        assert (user_path / \"logs.db\").exists()\n        db_path = str(user_path / \"logs.db\")\n    assert sqlite_utils.Database(db_path)[\"responses\"].count == 1\n\n\n@mock.patch.dict(os.environ, {\"OPENAI_API_KEY\": \"X\"})\n@pytest.mark.parametrize(\"use_stdin\", (True, False, \"split\"))\n@pytest.mark.parametrize(\n    \"logs_off,logs_args,should_log\",\n    (\n        (True, [], False),\n        (False, [], True),\n        (False, [\"--no-log\"], False),\n        (False, [\"--log\"], True),\n        (True, [\"-n\"], False),  # Short for --no-log\n        (True, [\"--log\"], True),\n    ),\n)\ndef test_llm_default_prompt(\n    mocked_openai_chat, use_stdin, user_path, logs_off, logs_args, should_log\n):\n    # Reset the log_path database\n    log_path = user_path / \"logs.db\"\n    log_db = sqlite_utils.Database(str(log_path))\n    log_db[\"responses\"].delete_where()\n\n    logs_off_path = user_path / \"logs-off\"\n    if logs_off:\n        # Turn off logging\n        assert not logs_off_path.exists()\n        CliRunner().invoke(cli, [\"logs\", \"off\"])\n        assert logs_off_path.exists()\n    else:\n        # Turn on logging\n        CliRunner().invoke(cli, [\"logs\", \"on\"])\n        assert not logs_off_path.exists()\n\n    # Run the prompt\n    runner = CliRunner()\n    prompt = \"three names \\nfor a pet pelican\"\n    input = None\n    args = [\"--no-stream\"]\n    if use_stdin == \"split\":\n        input = \"three names\"\n        args.append(\"\\nfor a pet pelican\")\n    elif use_stdin:\n        input = prompt\n    else:\n        args.append(prompt)\n    args += logs_args\n    result = runner.invoke(cli, args, input=input, catch_exceptions=False)\n    assert result.exit_code == 0\n    assert result.output == \"Bob, Alice, Eve\\n\"\n    last_request = mocked_openai_chat.get_requests()[-1]\n    assert last_request.headers[\"Authorization\"] == \"Bearer X\"\n\n    # Was it logged?\n    rows = list(log_db[\"responses\"].rows)\n\n    if not should_log:\n        assert len(rows) == 0\n        return\n\n    assert len(rows) == 1\n    expected = {\n        \"model\": \"gpt-4o-mini\",\n        \"prompt\": \"three names \\nfor a pet pelican\",\n        \"system\": None,\n        \"options_json\": \"{}\",\n        \"response\": \"Bob, Alice, Eve\",\n    }\n    row = rows[0]\n    assert expected.items() <= row.items()\n    assert isinstance(row[\"duration_ms\"], int)\n    assert isinstance(row[\"datetime_utc\"], str)\n    assert json.loads(row[\"prompt_json\"]) == {\n        \"messages\": [{\"role\": \"user\", \"content\": \"three names \\nfor a pet pelican\"}]\n    }\n    assert json.loads(row[\"response_json\"]) == {\n        \"choices\": [{\"message\": {\"content\": {\"$\": f\"r:{row['id']}\"}}}],\n        \"model\": \"gpt-4o-mini\",\n    }\n\n    # Test \"llm logs\"\n    log_result = runner.invoke(\n        cli, [\"logs\", \"-n\", \"1\", \"--json\"], catch_exceptions=False\n    )\n    log_json = json.loads(log_result.output)\n\n    # Should have logged correctly:\n    assert (\n        log_json[0].items()\n        >= {\n            \"model\": \"gpt-4o-mini\",\n            \"prompt\": \"three names \\nfor a pet pelican\",\n            \"system\": None,\n            \"prompt_json\": {\n                \"messages\": [\n                    {\"role\": \"user\", \"content\": \"three names \\nfor a pet pelican\"}\n                ]\n            },\n            \"options_json\": {},\n            \"response\": \"Bob, Alice, Eve\",\n            \"response_json\": {\n                \"model\": \"gpt-4o-mini\",\n                \"choices\": [{\"message\": {\"content\": {\"$\": f\"r:{row['id']}\"}}}],\n            },\n            # This doesn't have the \\n after three names:\n            \"conversation_name\": \"three names for a pet pelican\",\n            \"conversation_model\": \"gpt-4o-mini\",\n        }.items()\n    )\n\n\n@mock.patch.dict(os.environ, {\"OPENAI_API_KEY\": \"X\"})\n@pytest.mark.parametrize(\"async_\", (False, True))\ndef test_llm_prompt_continue(httpx_mock, user_path, async_):\n    httpx_mock.add_response(\n        method=\"POST\",\n        url=\"https://api.openai.com/v1/chat/completions\",\n        json={\n            \"model\": \"gpt-4o-mini\",\n            \"usage\": {},\n            \"choices\": [{\"message\": {\"content\": \"Bob, Alice, Eve\"}}],\n        },\n        headers={\"Content-Type\": \"application/json\"},\n    )\n    httpx_mock.add_response(\n        method=\"POST\",\n        url=\"https://api.openai.com/v1/chat/completions\",\n        json={\n            \"model\": \"gpt-4o-mini\",\n            \"usage\": {},\n            \"choices\": [{\"message\": {\"content\": \"Terry\"}}],\n        },\n        headers={\"Content-Type\": \"application/json\"},\n    )\n\n    log_path = user_path / \"logs.db\"\n    log_db = sqlite_utils.Database(str(log_path))\n    log_db[\"responses\"].delete_where()\n\n    # First prompt\n    runner = CliRunner()\n    args = [\"three names \\nfor a pet pelican\", \"--no-stream\"] + (\n        [\"--async\"] if async_ else []\n    )\n    result = runner.invoke(cli, args, catch_exceptions=False)\n    assert result.exit_code == 0, result.output\n    assert result.output == \"Bob, Alice, Eve\\n\"\n\n    # Should be logged\n    rows = list(log_db[\"responses\"].rows)\n    assert len(rows) == 1\n\n    # Now ask a follow-up\n    args2 = [\"one more\", \"-c\", \"--no-stream\"] + ([\"--async\"] if async_ else [])\n    result2 = runner.invoke(cli, args2, catch_exceptions=False)\n    assert result2.exit_code == 0, result2.output\n    assert result2.output == \"Terry\\n\"\n\n    rows = list(log_db[\"responses\"].rows)\n    assert len(rows) == 2\n\n\n@pytest.mark.parametrize(\n    \"args,expect_just_code\",\n    (\n        ([\"-x\"], True),\n        ([\"--extract\"], True),\n        ([\"-x\", \"--async\"], True),\n        ([\"--extract\", \"--async\"], True),\n        # Use --no-stream here to ensure it passes test same as -x/--extract cases\n        ([\"--no-stream\"], False),\n    ),\n)\ndef test_extract_fenced_code(\n    mocked_openai_chat_returning_fenced_code, args, expect_just_code\n):\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\"-m\", \"gpt-4o-mini\", \"--key\", \"x\", \"Write code\"] + args,\n        catch_exceptions=False,\n    )\n    output = result.output\n    if expect_just_code:\n        assert \"```\" not in output\n    else:\n        assert \"```\" in output\n\n\ndef test_openai_chat_stream(mocked_openai_chat_stream, user_path):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"-m\", \"gpt-3.5-turbo\", \"--key\", \"x\", \"Say hi\"])\n    assert result.exit_code == 0\n    assert result.output == \"Hi.\\n\"\n\n\ndef test_openai_completion(mocked_openai_completion, user_path):\n    log_path = user_path / \"logs.db\"\n    log_db = sqlite_utils.Database(str(log_path))\n    log_db[\"responses\"].delete_where()\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"-m\",\n            \"gpt-3.5-turbo-instruct\",\n            \"Say this is a test\",\n            \"--no-stream\",\n            \"--key\",\n            \"x\",\n        ],\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    assert result.output == \"\\n\\nThis is indeed a test\\n\"\n\n    # Should have requested 256 tokens\n    last_request = mocked_openai_completion.get_requests()[-1]\n    assert json.loads(last_request.content) == {\n        \"model\": \"gpt-3.5-turbo-instruct\",\n        \"prompt\": \"Say this is a test\",\n        \"stream\": False,\n        \"max_tokens\": 256,\n    }\n\n    # Check it was logged\n    rows = list(log_db[\"responses\"].rows)\n    assert len(rows) == 1\n    expected = {\n        \"model\": \"gpt-3.5-turbo-instruct\",\n        \"prompt\": \"Say this is a test\",\n        \"system\": None,\n        \"prompt_json\": '{\"messages\": [\"Say this is a test\"]}',\n        \"options_json\": \"{}\",\n        \"response\": \"\\n\\nThis is indeed a test\",\n    }\n    row = rows[0]\n    assert expected.items() <= row.items()\n\n\ndef test_openai_completion_system_prompt_error():\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"-m\",\n            \"gpt-3.5-turbo-instruct\",\n            \"Say this is a test\",\n            \"--no-stream\",\n            \"--key\",\n            \"x\",\n            \"--system\",\n            \"system prompts not allowed\",\n        ],\n    )\n    assert result.exit_code == 1\n    assert (\n        \"System prompts are not supported for OpenAI completion models\" in result.output\n    )\n\n\ndef test_openai_completion_logprobs_stream(\n    mocked_openai_completion_logprobs_stream, user_path\n):\n    log_path = user_path / \"logs.db\"\n    log_db = sqlite_utils.Database(str(log_path))\n    log_db[\"responses\"].delete_where()\n    runner = CliRunner()\n    args = [\n        \"-m\",\n        \"gpt-3.5-turbo-instruct\",\n        \"Say hi\",\n        \"-o\",\n        \"logprobs\",\n        \"2\",\n        \"--key\",\n        \"x\",\n    ]\n    result = runner.invoke(cli, args, catch_exceptions=False)\n    assert result.exit_code == 0\n    assert result.output == \"\\n\\nHi.\\n\"\n    rows = list(log_db[\"responses\"].rows)\n    assert len(rows) == 1\n    row = rows[0]\n    assert json.loads(row[\"response_json\"]) == {\n        \"content\": {\"$\": f'r:{row[\"id\"]}'},\n        \"logprobs\": [\n            {\"text\": \"\\n\\n\", \"top_logprobs\": [{\"\\n\\n\": -0.6, \"\\n\": -1.9}]},\n            {\"text\": \"Hi\", \"top_logprobs\": [{\"Hi\": -1.1, \"Hello\": -0.7}]},\n            {\"text\": \".\", \"top_logprobs\": [{\".\": -1.1, \"!\": -0.9}]},\n            {\"text\": \"\", \"top_logprobs\": []},\n        ],\n        \"id\": \"cmpl-80MdSaou7NnPuff5ZyRMysWBmgSPS\",\n        \"object\": \"text_completion\",\n        \"model\": \"gpt-3.5-turbo-instruct\",\n        \"created\": 1695097702,\n    }\n\n\ndef test_openai_completion_logprobs_nostream(\n    mocked_openai_completion_logprobs, user_path\n):\n    log_path = user_path / \"logs.db\"\n    log_db = sqlite_utils.Database(str(log_path))\n    log_db[\"responses\"].delete_where()\n    runner = CliRunner()\n    args = [\n        \"-m\",\n        \"gpt-3.5-turbo-instruct\",\n        \"Say hi\",\n        \"-o\",\n        \"logprobs\",\n        \"2\",\n        \"--key\",\n        \"x\",\n        \"--no-stream\",\n    ]\n    result = runner.invoke(cli, args, catch_exceptions=False)\n    assert result.exit_code == 0\n    assert result.output == \"\\n\\nHi.\\n\"\n    rows = list(log_db[\"responses\"].rows)\n    assert len(rows) == 1\n    row = rows[0]\n    assert json.loads(row[\"response_json\"]) == {\n        \"choices\": [\n            {\n                \"finish_reason\": \"stop\",\n                \"index\": 0,\n                \"logprobs\": {\n                    \"text_offset\": [16, 18, 20],\n                    \"token_logprobs\": [-0.6, -1.1, -0.9],\n                    \"tokens\": [\"\\n\\n\", \"Hi\", \"1\"],\n                    \"top_logprobs\": [\n                        {\"\\n\": -1.9, \"\\n\\n\": -0.6},\n                        {\"Hello\": -0.7, \"Hi\": -1.1},\n                        {\"!\": -1.1, \".\": -0.9},\n                    ],\n                },\n                \"text\": {\"$\": f\"r:{row['id']}\"},\n            }\n        ],\n        \"created\": 1695097747,\n        \"id\": \"cmpl-80MeBfKJutM0uMNJkRrebJLeP3bxL\",\n        \"model\": \"gpt-3.5-turbo-instruct\",\n        \"object\": \"text_completion\",\n        \"usage\": {\"completion_tokens\": 3, \"prompt_tokens\": 5, \"total_tokens\": 8},\n    }\n\n\nEXTRA_MODELS_YAML = \"\"\"\n- model_id: orca\n  model_name: orca-mini-3b\n  api_base: \"http://localai.localhost\"\n- model_id: completion-babbage\n  model_name: babbage\n  api_base: \"http://localai.localhost\"\n  completion: 1\n\"\"\"\n\n\ndef test_openai_localai_configuration(mocked_localai, user_path):\n    log_path = user_path / \"logs.db\"\n    sqlite_utils.Database(str(log_path))\n    # Write the configuration file\n    config_path = user_path / \"extra-openai-models.yaml\"\n    config_path.write_text(EXTRA_MODELS_YAML, \"utf-8\")\n    # Run the prompt\n    runner = CliRunner()\n    prompt = \"three names \\nfor a pet pelican\"\n    result = runner.invoke(cli, [\"--no-stream\", \"--model\", \"orca\", prompt])\n    assert result.exit_code == 0\n    assert result.output == \"Bob, Alice, Eve\\n\"\n    last_request = mocked_localai.get_requests()[-1]\n    assert json.loads(last_request.content) == {\n        \"model\": \"orca-mini-3b\",\n        \"messages\": [{\"role\": \"user\", \"content\": \"three names \\nfor a pet pelican\"}],\n        \"stream\": False,\n    }\n    # And check the completion model too\n    result2 = runner.invoke(cli, [\"--no-stream\", \"--model\", \"completion-babbage\", \"hi\"])\n    assert result2.exit_code == 0\n    assert result2.output == \"Hello\\n\"\n    last_request2 = mocked_localai.get_requests()[-1]\n    assert json.loads(last_request2.content) == {\n        \"model\": \"babbage\",\n        \"prompt\": \"hi\",\n        \"stream\": False,\n    }\n\n\n@pytest.mark.parametrize(\n    \"args,exit_code\",\n    (\n        ([\"-q\", \"mo\", \"-q\", \"ck\"], 0),\n        ([\"-q\", \"mock\"], 0),\n        ([\"-q\", \"badmodel\"], 1),\n        ([\"-q\", \"mock\", \"-q\", \"badmodel\"], 1),\n    ),\n)\ndef test_prompt_select_model_with_queries(mock_model, user_path, args, exit_code):\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        args + [\"hello\"],\n        catch_exceptions=False,\n    )\n    assert result.exit_code == exit_code\n\n\nEXPECTED_OPTIONS = \"\"\"\nOpenAI Chat: gpt-4o (aliases: 4o)\n  Options:\n    temperature: float\n      What sampling temperature to use, between 0 and 2. Higher values like\n      0.8 will make the output more random, while lower values like 0.2 will\n      make it more focused and deterministic.\n    max_tokens: int\n      Maximum number of tokens to generate.\n    top_p: float\n      An alternative to sampling with temperature, called nucleus sampling,\n      where the model considers the results of the tokens with top_p\n      probability mass. So 0.1 means only the tokens comprising the top 10%\n      probability mass are considered. Recommended to use top_p or\n      temperature but not both.\n    frequency_penalty: float\n      Number between -2.0 and 2.0. Positive values penalize new tokens based\n      on their existing frequency in the text so far, decreasing the model's\n      likelihood to repeat the same line verbatim.\n    presence_penalty: float\n      Number between -2.0 and 2.0. Positive values penalize new tokens based\n      on whether they appear in the text so far, increasing the model's\n      likelihood to talk about new topics.\n    stop: str\n      A string where the API will stop generating further tokens.\n    logit_bias: dict, str\n      Modify the likelihood of specified tokens appearing in the completion.\n      Pass a JSON string like '{\"1712\":-100, \"892\":-100, \"1489\":-100}'\n    seed: int\n      Integer seed to attempt to sample deterministically\n    json_object: boolean\n      Output a valid JSON object {...}. Prompt must mention JSON.\n  Attachment types:\n    application/pdf, image/gif, image/jpeg, image/png, image/webp\n  Keys:\n    key: openai\n    env_var: OPENAI_API_KEY\n\"\"\"\n\n\ndef test_llm_models_options(user_path):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"models\", \"--options\"], catch_exceptions=False)\n    assert result.exit_code == 0\n    # Check for key components instead of exact string match\n    assert \"OpenAI Chat: gpt-4o (aliases: 4o)\" in result.output\n    assert \"  Options:\" in result.output\n    assert \"    temperature: float\" in result.output\n    assert \"  Keys:\" in result.output\n    assert \"    key: openai\" in result.output\n    assert \"    env_var: OPENAI_API_KEY\" in result.output\n    assert \"AsyncMockModel (async): mock\" not in result.output\n\n\ndef test_llm_models_async(user_path):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"models\", \"--async\"], catch_exceptions=False)\n    assert result.exit_code == 0\n    assert \"AsyncMockModel (async): mock\" in result.output\n\n\n@pytest.mark.parametrize(\n    \"args,expected_model_ids,unexpected_model_ids\",\n    (\n        ([\"-q\", \"gpt-4o\"], [\"OpenAI Chat: gpt-4o\"], None),\n        ([\"-q\", \"mock\"], [\"MockModel: mock\"], None),\n        ([\"--query\", \"mock\"], [\"MockModel: mock\"], None),\n        (\n            [\"-q\", \"4o\", \"-q\", \"mini\"],\n            [\"OpenAI Chat: gpt-4o-mini\"],\n            [\"OpenAI Chat: gpt-4o \"],\n        ),\n        (\n            [\"-m\", \"gpt-4o-mini\", \"-m\", \"gpt-4.5\"],\n            [\"OpenAI Chat: gpt-4o-mini\", \"OpenAI Chat: gpt-4.5\"],\n            [\"OpenAI Chat: gpt-4o \"],\n        ),\n    ),\n)\ndef test_llm_models_filter(user_path, args, expected_model_ids, unexpected_model_ids):\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"models\"] + args, catch_exceptions=False)\n    assert result.exit_code == 0\n    if expected_model_ids:\n        for expected_model_id in expected_model_ids:\n            assert expected_model_id in result.output\n    if unexpected_model_ids:\n        for unexpected_model_id in unexpected_model_ids:\n            assert unexpected_model_id not in result.output\n\n\ndef test_llm_user_dir(tmpdir, monkeypatch):\n    user_dir = str(tmpdir / \"u\")\n    monkeypatch.setenv(\"LLM_USER_PATH\", user_dir)\n    assert not os.path.exists(user_dir)\n    user_dir2 = llm.user_dir()\n    assert user_dir == str(user_dir2)\n    assert os.path.exists(user_dir)\n\n\ndef test_model_defaults(tmpdir, monkeypatch):\n    user_dir = str(tmpdir / \"u\")\n    monkeypatch.setenv(\"LLM_USER_PATH\", user_dir)\n    config_path = pathlib.Path(user_dir) / \"default_model.txt\"\n    assert not config_path.exists()\n    assert llm.get_default_model() == \"gpt-4o-mini\"\n    assert llm.get_model().model_id == \"gpt-4o-mini\"\n    llm.set_default_model(\"gpt-4o\")\n    assert config_path.exists()\n    assert llm.get_default_model() == \"gpt-4o\"\n    assert llm.get_model().model_id == \"gpt-4o\"\n\n\ndef test_get_models():\n    models = llm.get_models()\n    assert all(isinstance(model, (llm.Model, llm.KeyModel)) for model in models)\n    model_ids = [model.model_id for model in models]\n    assert \"gpt-4o-mini\" in model_ids\n    assert \"gpt-5.4-mini\" in model_ids\n    assert \"gpt-5.4-nano\" in model_ids\n    # Ensure no model_ids are duplicated\n    # https://github.com/simonw/llm/issues/667\n    assert len(model_ids) == len(set(model_ids))\n\n\ndef test_get_async_models():\n    models = llm.get_async_models()\n    assert all(\n        isinstance(model, (llm.AsyncModel, llm.AsyncKeyModel)) for model in models\n    )\n    model_ids = [model.model_id for model in models]\n    assert \"gpt-4o-mini\" in model_ids\n    assert \"gpt-5.4-mini\" in model_ids\n    assert \"gpt-5.4-nano\" in model_ids\n\n\ndef test_mock_model(mock_model):\n    mock_model.enqueue([\"hello world\"])\n    mock_model.enqueue([\"second\"])\n    model = llm.get_model(\"mock\")\n    response = model.prompt(prompt=\"hello\")\n    assert response.text() == \"hello world\"\n    assert str(response) == \"hello world\"\n    assert model.history[0][0].prompt == \"hello\"\n    assert response.usage() == Usage(input=1, output=1, details=None)\n    response2 = model.prompt(prompt=\"hello again\")\n    assert response2.text() == \"second\"\n    assert response2.usage() == Usage(input=2, output=1, details=None)\n\n\nclass Dog(BaseModel):\n    name: str\n    age: int\n\n\ndog_schema = {\n    \"properties\": {\n        \"name\": {\"title\": \"Name\", \"type\": \"string\"},\n        \"age\": {\"title\": \"Age\", \"type\": \"integer\"},\n    },\n    \"required\": [\"name\", \"age\"],\n    \"title\": \"Dog\",\n    \"type\": \"object\",\n}\ndog = {\"name\": \"Cleo\", \"age\": 10}\n\n\n@pytest.mark.parametrize(\"use_pydantic\", (False, True))\ndef test_schema(mock_model, use_pydantic):\n    assert dog_schema == Dog.model_json_schema()\n    mock_model.enqueue([json.dumps(dog)])\n    response = mock_model.prompt(\n        \"invent a dog\", schema=Dog if use_pydantic else dog_schema\n    )\n    assert json.loads(response.text()) == dog\n    assert response.prompt.schema == dog_schema\n\n\ndef test_model_environment_variable(monkeypatch):\n    monkeypatch.setenv(\"LLM_MODEL\", \"echo\")\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\"--no-stream\", \"hello\", \"-s\", \"sys\"],\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    assert json.loads(result.output) == {\n        \"prompt\": \"hello\",\n        \"system\": \"sys\",\n        \"attachments\": [],\n        \"stream\": False,\n        \"previous\": [],\n    }\n\n\n@pytest.mark.parametrize(\"use_filename\", (True, False))\ndef test_schema_via_cli(mock_model, tmpdir, monkeypatch, use_filename):\n    user_path = tmpdir / \"user\"\n    schema_path = tmpdir / \"schema.json\"\n    mock_model.enqueue([json.dumps(dog)])\n    schema_value = '{\"schema\": \"one\"}'\n    with open(schema_path, \"w\") as f:\n        f.write(schema_value)\n    monkeypatch.setenv(\"LLM_USER_PATH\", str(user_path))\n    if use_filename:\n        schema_value = str(schema_path)\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\"--schema\", schema_value, \"prompt\", \"-m\", \"mock\"],\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    assert result.output == '{\"name\": \"Cleo\", \"age\": 10}\\n'\n    # Should have created user_path and put a logs.db in it\n    assert (user_path / \"logs.db\").exists()\n    rows = list(sqlite_utils.Database(str(user_path / \"logs.db\"))[\"schemas\"].rows)\n    assert rows == [\n        {\"id\": \"9a8ed2c9b17203f6d8905147234475b5\", \"content\": '{\"schema\":\"one\"}'}\n    ]\n    if use_filename:\n        # Run it again to check that the ID option works now it's in the DB\n        result2 = runner.invoke(\n            cli,\n            [\"--schema\", \"9a8ed2c9b17203f6d8905147234475b5\", \"prompt\", \"-m\", \"mock\"],\n            catch_exceptions=False,\n        )\n        assert result2.exit_code == 0\n\n\n@pytest.mark.parametrize(\n    \"args,expected\",\n    (\n        (\n            [\"--schema\", \"name, age int\"],\n            {\n                \"type\": \"object\",\n                \"properties\": {\"name\": {\"type\": \"string\"}, \"age\": {\"type\": \"integer\"}},\n                \"required\": [\"name\", \"age\"],\n            },\n        ),\n        (\n            [\"--schema-multi\", \"name, age int\"],\n            {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"items\": {\n                        \"type\": \"array\",\n                        \"items\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                                \"name\": {\"type\": \"string\"},\n                                \"age\": {\"type\": \"integer\"},\n                            },\n                            \"required\": [\"name\", \"age\"],\n                        },\n                    }\n                },\n                \"required\": [\"items\"],\n            },\n        ),\n    ),\n)\ndef test_schema_using_dsl(mock_model, tmpdir, monkeypatch, args, expected):\n    user_path = tmpdir / \"user\"\n    mock_model.enqueue([json.dumps(dog)])\n    monkeypatch.setenv(\"LLM_USER_PATH\", str(user_path))\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\"prompt\", \"-m\", \"mock\"] + args,\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    assert result.output == '{\"name\": \"Cleo\", \"age\": 10}\\n'\n    rows = list(sqlite_utils.Database(str(user_path / \"logs.db\"))[\"schemas\"].rows)\n    assert json.loads(rows[0][\"content\"]) == expected\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"use_pydantic\", (False, True))\nasync def test_schema_async(async_mock_model, use_pydantic):\n    async_mock_model.enqueue([json.dumps(dog)])\n    response = async_mock_model.prompt(\n        \"invent a dog\", schema=Dog if use_pydantic else dog_schema\n    )\n    assert json.loads(await response.text()) == dog\n    assert response.prompt.schema == dog_schema\n\n\ndef test_mock_key_model(mock_key_model):\n    response = mock_key_model.prompt(prompt=\"hello\", key=\"hi\")\n    assert response.text() == \"key: hi\"\n\n\n@pytest.mark.asyncio\nasync def test_mock_async_key_model(mock_async_key_model):\n    response = mock_async_key_model.prompt(prompt=\"hello\", key=\"hi\")\n    output = await response.text()\n    assert output == \"async, key: hi\"\n\n\ndef test_sync_on_done(mock_model):\n    mock_model.enqueue([\"hello world\"])\n    model = llm.get_model(\"mock\")\n    response = model.prompt(prompt=\"hello\")\n    caught = []\n\n    def done(response):\n        caught.append(response)\n\n    response.on_done(done)\n    assert len(caught) == 0\n    str(response)\n    assert len(caught) == 1\n\n\ndef test_schemas_dsl():\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"schemas\", \"dsl\", \"name, age int, bio: short bio\"])\n    assert result.exit_code == 0\n    assert json.loads(result.output) == {\n        \"type\": \"object\",\n        \"properties\": {\n            \"name\": {\"type\": \"string\"},\n            \"age\": {\"type\": \"integer\"},\n            \"bio\": {\"type\": \"string\", \"description\": \"short bio\"},\n        },\n        \"required\": [\"name\", \"age\", \"bio\"],\n    }\n    result2 = runner.invoke(cli, [\"schemas\", \"dsl\", \"name, age int\", \"--multi\"])\n    assert result2.exit_code == 0\n    assert json.loads(result2.output) == {\n        \"type\": \"object\",\n        \"properties\": {\n            \"items\": {\n                \"type\": \"array\",\n                \"items\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"name\": {\"type\": \"string\"},\n                        \"age\": {\"type\": \"integer\"},\n                    },\n                    \"required\": [\"name\", \"age\"],\n                },\n            }\n        },\n        \"required\": [\"items\"],\n    }\n\n\n@mock.patch.dict(os.environ, {\"OPENAI_API_KEY\": \"X\"})\n@pytest.mark.parametrize(\"custom_database_path\", (False, True))\ndef test_llm_prompt_continue_with_database(\n    tmpdir, monkeypatch, httpx_mock, user_path, custom_database_path\n):\n    httpx_mock.add_response(\n        method=\"POST\",\n        url=\"https://api.openai.com/v1/chat/completions\",\n        json={\n            \"model\": \"gpt-4o-mini\",\n            \"usage\": {},\n            \"choices\": [{\"message\": {\"content\": \"Bob, Alice, Eve\"}}],\n        },\n        headers={\"Content-Type\": \"application/json\"},\n    )\n    httpx_mock.add_response(\n        method=\"POST\",\n        url=\"https://api.openai.com/v1/chat/completions\",\n        json={\n            \"model\": \"gpt-4o-mini\",\n            \"usage\": {},\n            \"choices\": [{\"message\": {\"content\": \"Terry\"}}],\n        },\n        headers={\"Content-Type\": \"application/json\"},\n    )\n\n    user_path = tmpdir / \"user\"\n    custom_db_path = tmpdir / \"custom_log.db\"\n    monkeypatch.setenv(\"LLM_USER_PATH\", str(user_path))\n\n    # First prompt\n    runner = CliRunner()\n    args = [\"three names \\nfor a pet pelican\", \"--no-stream\"]\n    if custom_database_path:\n        args.extend([\"--database\", str(custom_db_path)])\n    result = runner.invoke(cli, args, catch_exceptions=False)\n    assert result.exit_code == 0, result.output\n    assert result.output == \"Bob, Alice, Eve\\n\"\n\n    # Now ask a follow-up\n    args2 = [\"one more\", \"-c\", \"--no-stream\"]\n    if custom_database_path:\n        args2.extend([\"--database\", str(custom_db_path)])\n    result2 = runner.invoke(cli, args2, catch_exceptions=False)\n    assert result2.exit_code == 0, result2.output\n    assert result2.output == \"Terry\\n\"\n\n    if custom_database_path:\n        assert custom_db_path.exists()\n        db_path = str(custom_db_path)\n    else:\n        assert (user_path / \"logs.db\").exists()\n        db_path = str(user_path / \"logs.db\")\n    assert sqlite_utils.Database(db_path)[\"responses\"].count == 2\n\n\ndef test_default_exports():\n    \"Check key exports in the llm __all__ list\"\n    for name in (\"Model\", \"AsyncModel\", \"get_model\", \"get_async_model\", \"schema_dsl\"):\n        assert name in llm.__all__, f\"{name} not in llm.__all__\"\n"
  },
  {
    "path": "tests/test_llm_logs.py",
    "content": "from click.testing import CliRunner\nfrom llm.cli import cli\nfrom llm.migrations import migrate\nfrom llm.utils import monotonic_ulid\nfrom llm import Fragment\nimport datetime\nimport json\nimport pathlib\nimport pytest\nimport re\nimport sqlite_utils\nimport sys\nimport textwrap\nimport time\nfrom ulid import ULID\nimport yaml\n\nSINGLE_ID = \"5843577700ba729bb14c327b30441885\"\nMULTI_ID = \"4860edd987df587d042a9eb2b299ce5c\"\n\n\n@pytest.fixture\ndef log_path(user_path):\n    log_path = str(user_path / \"logs.db\")\n    db = sqlite_utils.Database(log_path)\n    migrate(db)\n    start = datetime.datetime.now(datetime.timezone.utc)\n    db[\"responses\"].insert_all(\n        {\n            \"id\": str(monotonic_ulid()).lower(),\n            \"system\": \"system\",\n            \"prompt\": \"prompt\",\n            \"response\": 'response\\n```python\\nprint(\"hello word\")\\n```',\n            \"model\": \"davinci\",\n            \"datetime_utc\": (start + datetime.timedelta(seconds=i)).isoformat(),\n            \"conversation_id\": \"abc123\",\n            \"input_tokens\": 2,\n            \"output_tokens\": 5,\n        }\n        for i in range(100)\n    )\n    return log_path\n\n\n@pytest.fixture\ndef schema_log_path(user_path):\n    log_path = str(user_path / \"logs_schema.db\")\n    db = sqlite_utils.Database(log_path)\n    migrate(db)\n    start = datetime.datetime.now(datetime.timezone.utc)\n    db[\"schemas\"].insert({\"id\": SINGLE_ID, \"content\": '{\"name\": \"string\"}'})\n    db[\"schemas\"].insert({\"id\": MULTI_ID, \"content\": '{\"name\": \"array\"}'})\n    for i in range(2):\n        db[\"responses\"].insert(\n            {\n                \"id\": str(ULID.from_timestamp(time.time() + i)).lower(),\n                \"system\": \"system\",\n                \"prompt\": \"prompt\",\n                \"response\": '{\"name\": \"' + str(i) + '\"}',\n                \"model\": \"davinci\",\n                \"datetime_utc\": (start + datetime.timedelta(seconds=i)).isoformat(),\n                \"conversation_id\": \"abc123\",\n                \"input_tokens\": 2,\n                \"output_tokens\": 5,\n                \"schema_id\": SINGLE_ID,\n            }\n        )\n    for j in range(4):\n        db[\"responses\"].insert(\n            {\n                \"id\": str(ULID.from_timestamp(time.time() + j)).lower(),\n                \"system\": \"system\",\n                \"prompt\": \"prompt\",\n                \"response\": '{\"items\": [{\"name\": \"one\"}, {\"name\": \"two\"}]}',\n                \"model\": \"davinci\",\n                \"datetime_utc\": (start + datetime.timedelta(seconds=i)).isoformat(),\n                \"conversation_id\": \"abc456\",\n                \"input_tokens\": 2,\n                \"output_tokens\": 5,\n                \"schema_id\": MULTI_ID,\n            }\n        )\n\n    return log_path\n\n\ndatetime_re = re.compile(r\"\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\")\nid_re = re.compile(r\"id: \\w+\")\n\n\n@pytest.mark.parametrize(\"usage\", (False, True))\ndef test_logs_text(log_path, usage):\n    runner = CliRunner()\n    args = [\"logs\", \"-p\", str(log_path)]\n    if usage:\n        args.append(\"-u\")\n    result = runner.invoke(cli, args, catch_exceptions=False)\n    assert result.exit_code == 0\n    output = result.output\n    # Replace 2023-08-17T20:53:58 with YYYY-MM-DDTHH:MM:SS\n    output = datetime_re.sub(\"YYYY-MM-DDTHH:MM:SS\", output)\n    # Replace id: whatever with id: xxx\n    output = id_re.sub(\"id: xxx\", output)\n    expected = (\n        (\n            \"# YYYY-MM-DDTHH:MM:SS    conversation: abc123 id: xxx\\n\\n\"\n            \"Model: **davinci**\\n\\n\"\n            \"## Prompt\\n\\n\"\n            \"prompt\\n\\n\"\n            \"## System\\n\\n\"\n            \"system\\n\\n\"\n            \"## Response\\n\\n\"\n            'response\\n```python\\nprint(\"hello word\")\\n```\\n\\n'\n        )\n        + (\"## Token usage\\n\\n2 input, 5 output\\n\\n\" if usage else \"\")\n        + (\n            \"# YYYY-MM-DDTHH:MM:SS    conversation: abc123 id: xxx\\n\\n\"\n            \"Model: **davinci**\\n\\n\"\n            \"## Prompt\\n\\n\"\n            \"prompt\\n\\n\"\n            \"## Response\\n\\n\"\n            'response\\n```python\\nprint(\"hello word\")\\n```\\n\\n'\n        )\n        + (\"## Token usage\\n\\n2 input, 5 output\\n\\n\" if usage else \"\")\n        + (\n            \"# YYYY-MM-DDTHH:MM:SS    conversation: abc123 id: xxx\\n\\n\"\n            \"Model: **davinci**\\n\\n\"\n            \"## Prompt\\n\\n\"\n            \"prompt\\n\\n\"\n            \"## Response\\n\\n\"\n            'response\\n```python\\nprint(\"hello word\")\\n```\\n\\n'\n        )\n        + (\"## Token usage\\n\\n2 input, 5 output\\n\\n\" if usage else \"\")\n    )\n    assert output == expected\n\n\ndef test_logs_text_with_options(user_path):\n    \"\"\"Test that ## Options section appears when options_json is set\"\"\"\n    log_path = str(user_path / \"logs_with_options.db\")\n    db = sqlite_utils.Database(log_path)\n    migrate(db)\n    start = datetime.datetime.now(datetime.timezone.utc)\n\n    # Create response with options\n    db[\"responses\"].insert(\n        {\n            \"id\": str(monotonic_ulid()).lower(),\n            \"system\": \"system\",\n            \"prompt\": \"prompt\",\n            \"response\": \"response\",\n            \"model\": \"davinci\",\n            \"datetime_utc\": start.isoformat(),\n            \"conversation_id\": \"abc123\",\n            \"input_tokens\": 2,\n            \"output_tokens\": 5,\n            \"options_json\": json.dumps(\n                {\"thinking_level\": \"high\", \"media_resolution\": \"low\"}\n            ),\n        }\n    )\n\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"logs\", \"-p\", str(log_path)], catch_exceptions=False)\n    assert result.exit_code == 0\n    output = result.output\n\n    # Verify ## Options section is present\n    assert \"## Options\\n\\n\" in output\n    assert \"- thinking_level: high\" in output\n    assert \"- media_resolution: low\" in output\n\n\n@pytest.mark.parametrize(\"n\", (None, 0, 2))\ndef test_logs_json(n, log_path):\n    \"Test that logs command correctly returns requested -n records\"\n    runner = CliRunner()\n    args = [\"logs\", \"-p\", str(log_path), \"--json\"]\n    if n is not None:\n        args.extend([\"-n\", str(n)])\n    result = runner.invoke(cli, args, catch_exceptions=False)\n    assert result.exit_code == 0\n    logs = json.loads(result.output)\n    expected_length = 3\n    if n is not None:\n        if n == 0:\n            expected_length = 100\n        else:\n            expected_length = n\n    assert len(logs) == expected_length\n\n\n@pytest.mark.parametrize(\n    \"args\", ([\"-r\"], [\"--response\"], [\"list\", \"-r\"], [\"list\", \"--response\"])\n)\ndef test_logs_response_only(args, log_path):\n    \"Test that logs -r/--response returns just the last response\"\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"logs\"] + args, catch_exceptions=False)\n    assert result.exit_code == 0\n    assert result.output == 'response\\n```python\\nprint(\"hello word\")\\n```\\n'\n\n\n@pytest.mark.parametrize(\n    \"args\",\n    (\n        [\"-x\"],\n        [\"--extract\"],\n        [\"list\", \"-x\"],\n        [\"list\", \"--extract\"],\n        # Using -xr together should have same effect as just -x\n        [\"-xr\"],\n        [\"-x\", \"-r\"],\n        [\"--extract\", \"--response\"],\n    ),\n)\ndef test_logs_extract_first_code(args, log_path):\n    \"Test that logs -x/--extract returns the first code block\"\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"logs\"] + args, catch_exceptions=False)\n    assert result.exit_code == 0\n    assert result.output == 'print(\"hello word\")\\n\\n'\n\n\n@pytest.mark.parametrize(\n    \"args\",\n    (\n        [\"--xl\"],\n        [\"--extract-last\"],\n        [\"list\", \"--xl\"],\n        [\"list\", \"--extract-last\"],\n        [\"--xl\", \"-r\"],\n        [\"-x\", \"--xl\"],\n    ),\n)\ndef test_logs_extract_last_code(args, log_path):\n    \"Test that logs --xl/--extract-last returns the last code block\"\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"logs\"] + args, catch_exceptions=False)\n    assert result.exit_code == 0\n    assert result.output == 'print(\"hello word\")\\n\\n'\n\n\n@pytest.mark.parametrize(\"arg\", (\"-s\", \"--short\"))\n@pytest.mark.parametrize(\"usage\", (None, \"-u\", \"--usage\"))\ndef test_logs_short(log_path, arg, usage):\n    runner = CliRunner()\n    args = [\"logs\", arg, \"-p\", str(log_path)]\n    if usage:\n        args.append(usage)\n    result = runner.invoke(cli, args)\n    assert result.exit_code == 0\n    output = datetime_re.sub(\"YYYY-MM-DDTHH:MM:SS\", result.output)\n    expected_usage = \"\"\n    if usage:\n        expected_usage = \"  usage:\\n    input: 2\\n    output: 5\\n\"\n    expected = (\n        \"- model: davinci\\n\"\n        \"  datetime: 'YYYY-MM-DDTHH:MM:SS'\\n\"\n        \"  conversation: abc123\\n\"\n        \"  system: system\\n\"\n        \"  prompt: prompt\\n\"\n        \"  prompt_fragments: []\\n\"\n        f\"  system_fragments: []\\n{expected_usage}\"\n        \"- model: davinci\\n\"\n        \"  datetime: 'YYYY-MM-DDTHH:MM:SS'\\n\"\n        \"  conversation: abc123\\n\"\n        \"  system: system\\n\"\n        \"  prompt: prompt\\n\"\n        \"  prompt_fragments: []\\n\"\n        f\"  system_fragments: []\\n{expected_usage}\"\n        \"- model: davinci\\n\"\n        \"  datetime: 'YYYY-MM-DDTHH:MM:SS'\\n\"\n        \"  conversation: abc123\\n\"\n        \"  system: system\\n\"\n        \"  prompt: prompt\\n\"\n        \"  prompt_fragments: []\\n\"\n        f\"  system_fragments: []\\n{expected_usage}\"\n    )\n    assert output == expected\n\n\n@pytest.mark.xfail(sys.platform == \"win32\", reason=\"Expected to fail on Windows\")\n@pytest.mark.parametrize(\"env\", ({}, {\"LLM_USER_PATH\": \"/tmp/llm-user-path\"}))\ndef test_logs_path(monkeypatch, env, user_path):\n    for key, value in env.items():\n        monkeypatch.setenv(key, value)\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"logs\", \"path\"])\n    assert result.exit_code == 0\n    if env:\n        expected = env[\"LLM_USER_PATH\"] + \"/logs.db\"\n    else:\n        expected = str(user_path) + \"/logs.db\"\n    assert result.output.strip() == expected\n\n\n@pytest.mark.parametrize(\"model\", (\"davinci\", \"curie\"))\n@pytest.mark.parametrize(\"path_option\", (None, \"-p\", \"--path\", \"-d\", \"--database\"))\ndef test_logs_filtered(user_path, model, path_option):\n    log_path = str(user_path / \"logs.db\")\n    if path_option:\n        log_path = str(user_path / \"logs_alternative.db\")\n    db = sqlite_utils.Database(log_path)\n    migrate(db)\n    db[\"responses\"].insert_all(\n        {\n            \"id\": str(monotonic_ulid()).lower(),\n            \"system\": \"system\",\n            \"prompt\": \"prompt\",\n            \"response\": \"response\",\n            \"model\": \"davinci\" if i % 2 == 0 else \"curie\",\n        }\n        for i in range(100)\n    )\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\"logs\", \"list\", \"-m\", model, \"--json\"]\n        + ([path_option, log_path] if path_option else []),\n    )\n    assert result.exit_code == 0\n    records = json.loads(result.output.strip())\n    assert all(record[\"model\"] == model for record in records)\n\n\n@pytest.mark.parametrize(\n    \"query,extra_args,expected\",\n    (\n        # With no search term order should be by datetime\n        (\"\", [], [\"doc1\", \"doc2\", \"doc3\"]),\n        # With a search it's order by rank instead\n        (\"llama\", [], [\"doc1\", \"doc3\"]),\n        (\"alpaca\", [], [\"doc2\"]),\n        # Model filter should work too\n        (\"llama\", [\"-m\", \"davinci\"], [\"doc1\", \"doc3\"]),\n        (\"llama\", [\"-m\", \"davinci2\"], []),\n        # Adding -l/--latest should return latest first (order by id desc)\n        (\"llama\", [], [\"doc1\", \"doc3\"]),\n        (\"llama\", [\"-l\"], [\"doc3\", \"doc1\"]),\n        (\"llama\", [\"--latest\"], [\"doc3\", \"doc1\"]),\n    ),\n)\ndef test_logs_search(user_path, query, extra_args, expected):\n    log_path = str(user_path / \"logs.db\")\n    db = sqlite_utils.Database(log_path)\n    migrate(db)\n\n    def _insert(id, text):\n        db[\"responses\"].insert(\n            {\n                \"id\": id,\n                \"system\": \"system\",\n                \"prompt\": text,\n                \"response\": \"response\",\n                \"model\": \"davinci\",\n            }\n        )\n\n    _insert(\"doc1\", \"llama\")\n    _insert(\"doc2\", \"alpaca\")\n    _insert(\"doc3\", \"llama llama\")\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"logs\", \"list\", \"-q\", query, \"--json\"] + extra_args)\n    assert result.exit_code == 0\n    records = json.loads(result.output.strip())\n    assert [record[\"id\"] for record in records] == expected\n\n\n@pytest.mark.parametrize(\n    \"args,expected\",\n    (\n        ([\"--data\", \"--schema\", SINGLE_ID], '{\"name\": \"1\"}\\n{\"name\": \"0\"}\\n'),\n        (\n            [\"--data\", \"--schema\", MULTI_ID],\n            (\n                '{\"items\": [{\"name\": \"one\"}, {\"name\": \"two\"}]}\\n'\n                '{\"items\": [{\"name\": \"one\"}, {\"name\": \"two\"}]}\\n'\n                '{\"items\": [{\"name\": \"one\"}, {\"name\": \"two\"}]}\\n'\n                '{\"items\": [{\"name\": \"one\"}, {\"name\": \"two\"}]}\\n'\n            ),\n        ),\n        (\n            [\"--data-array\", \"--schema\", MULTI_ID],\n            (\n                '[{\"items\": [{\"name\": \"one\"}, {\"name\": \"two\"}]},\\n'\n                ' {\"items\": [{\"name\": \"one\"}, {\"name\": \"two\"}]},\\n'\n                ' {\"items\": [{\"name\": \"one\"}, {\"name\": \"two\"}]},\\n'\n                ' {\"items\": [{\"name\": \"one\"}, {\"name\": \"two\"}]}]\\n'\n            ),\n        ),\n        (\n            [\"--schema\", MULTI_ID, \"--data-key\", \"items\"],\n            (\n                '{\"name\": \"one\"}\\n'\n                '{\"name\": \"two\"}\\n'\n                '{\"name\": \"one\"}\\n'\n                '{\"name\": \"two\"}\\n'\n                '{\"name\": \"one\"}\\n'\n                '{\"name\": \"two\"}\\n'\n                '{\"name\": \"one\"}\\n'\n                '{\"name\": \"two\"}\\n'\n            ),\n        ),\n    ),\n)\ndef test_logs_schema(schema_log_path, args, expected):\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\"logs\", \"-n\", \"0\", \"-p\", str(schema_log_path)] + args,\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    assert result.output == expected\n\n\ndef test_logs_schema_data_ids(schema_log_path):\n    db = sqlite_utils.Database(schema_log_path)\n    ulid = ULID.from_timestamp(time.time() + 100)\n    db[\"responses\"].insert(\n        {\n            \"id\": str(ulid).lower(),\n            \"system\": \"system\",\n            \"prompt\": \"prompt\",\n            \"response\": json.dumps(\n                {\n                    \"name\": \"three\",\n                    \"response_id\": 1,\n                    \"conversation_id\": 2,\n                    \"conversation_id_\": 3,\n                }\n            ),\n            \"model\": \"davinci\",\n            \"datetime_utc\": ulid.datetime.isoformat(),\n            \"conversation_id\": \"abc123\",\n            \"input_tokens\": 2,\n            \"output_tokens\": 5,\n            \"schema_id\": SINGLE_ID,\n        }\n    )\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\n            \"logs\",\n            \"-n\",\n            \"0\",\n            \"-p\",\n            str(schema_log_path),\n            \"--data-ids\",\n            \"--data-key\",\n            \"items\",\n            \"--data-array\",\n        ],\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    rows = json.loads(result.output)\n    last_row = rows.pop(-1)\n    assert set(last_row.keys()) == {\n        \"conversation_id_\",\n        \"conversation_id\",\n        \"response_id\",\n        \"response_id_\",\n        \"name\",\n        \"conversation_id__\",\n    }\n    for row in rows:\n        assert set(row.keys()) == {\"conversation_id\", \"response_id\", \"name\"}\n\n\n_expected_yaml_re = r\"\"\"- id: [a-f0-9]{32}\n  summary: \\|\n    \n  usage: \\|\n    4 times, most recently \\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}\\+00:00\n- id: [a-f0-9]{32}\n  summary: \\|\n    \n  usage: \\|\n    2 times, most recently \\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}\\+00:00\"\"\"\n\n\n@pytest.mark.parametrize(\n    \"args,expected\",\n    (\n        ([\"schemas\"], _expected_yaml_re),\n        ([\"schemas\", \"list\"], _expected_yaml_re),\n    ),\n)\ndef test_schemas_list_yaml(schema_log_path, args, expected):\n    result = CliRunner().invoke(cli, args + [\"-d\", str(schema_log_path)])\n    assert result.exit_code == 0\n    assert re.match(expected, result.output.strip())\n\n\n@pytest.mark.parametrize(\"is_nl\", (False, True))\ndef test_schemas_list_json(schema_log_path, is_nl):\n    result = CliRunner().invoke(\n        cli,\n        [\"schemas\", \"list\"]\n        + ([\"--nl\"] if is_nl else [\"--json\"])\n        + [\"-d\", str(schema_log_path)],\n    )\n    assert result.exit_code == 0\n    if is_nl:\n        rows = [json.loads(line) for line in result.output.strip().split(\"\\n\")]\n    else:\n        rows = json.loads(result.output)\n    assert len(rows) == 2\n    assert rows[0][\"content\"] == {\"name\": \"array\"}\n    assert rows[0][\"times_used\"] == 4\n    assert rows[1][\"content\"] == {\"name\": \"string\"}\n    assert rows[1][\"times_used\"] == 2\n    assert set(rows[0].keys()) == {\"id\", \"content\", \"recently_used\", \"times_used\"}\n\n\n@pytest.fixture\ndef fragments_fixture(user_path):\n    log_path = str(user_path / \"logs_fragments.db\")\n    db = sqlite_utils.Database(log_path)\n    migrate(db)\n    start = datetime.datetime.now(datetime.timezone.utc)\n    # Replace everything from here on\n\n    fragment_hashes_by_slug = {}\n    # Create fragments\n    for i in range(1, 6):\n        content = f\"This is fragment {i}\" * (100 if i == 5 else 1)\n        fragment = Fragment(content, \"fragment\")\n        db[\"fragments\"].insert(\n            {\n                \"id\": i,\n                \"hash\": fragment.id(),\n                # 5 is a long one:\n                \"content\": content,\n                \"datetime_utc\": start.isoformat(),\n            }\n        )\n        db[\"fragment_aliases\"].insert({\"alias\": f\"hash{i}\", \"fragment_id\": i})\n        fragment_hashes_by_slug[f\"hash{i}\"] = fragment.id()\n\n    # Create some more fragment aliases\n    db[\"fragment_aliases\"].insert({\"alias\": \"alias_1\", \"fragment_id\": 3})\n    db[\"fragment_aliases\"].insert({\"alias\": \"alias_3\", \"fragment_id\": 4})\n    db[\"fragment_aliases\"].insert({\"alias\": \"long_5\", \"fragment_id\": 5})\n\n    def make_response(name, prompt_fragment_ids=None, system_fragment_ids=None):\n        time.sleep(0.05)  # To ensure ULIDs order predictably\n        response_id = str(ULID.from_timestamp(time.time())).lower()\n        db[\"responses\"].insert(\n            {\n                \"id\": response_id,\n                \"system\": f\"system: {name}\",\n                \"prompt\": f\"prompt: {name}\",\n                \"response\": f\"response: {name}\",\n                \"model\": \"davinci\",\n                \"datetime_utc\": start.isoformat(),\n                \"conversation_id\": \"abc123\",\n                \"input_tokens\": 2,\n                \"output_tokens\": 5,\n            }\n        )\n        # Link fragments to this response\n        for fragment_id in prompt_fragment_ids or []:\n            db[\"prompt_fragments\"].insert(\n                {\"response_id\": response_id, \"fragment_id\": fragment_id}\n            )\n        for fragment_id in system_fragment_ids or []:\n            db[\"system_fragments\"].insert(\n                {\"response_id\": response_id, \"fragment_id\": fragment_id}\n            )\n        return {name: response_id}\n\n    collected = {}\n    collected.update(make_response(\"no_fragments\"))\n    collected.update(\n        single_prompt_fragment_id=make_response(\"single_prompt_fragment\", [1])\n    )\n    collected.update(\n        single_system_fragment_id=make_response(\"single_system_fragment\", None, [2])\n    )\n    collected.update(\n        multi_prompt_fragment_id=make_response(\"multi_prompt_fragment\", [1, 2])\n    )\n    collected.update(\n        multi_system_fragment_id=make_response(\"multi_system_fragment\", None, [1, 2])\n    )\n    collected.update(both_fragments_id=make_response(\"both_fragments\", [1, 2], [3, 4]))\n    collected.update(\n        single_long_prompt_fragment_with_alias_id=make_response(\n            \"single_long_prompt_fragment_with_alias\", [5], None\n        )\n    )\n    collected.update(\n        single_system_fragment_with_alias_id=make_response(\n            \"single_system_fragment_with_alias\", None, [4]\n        )\n    )\n    return {\n        \"path\": log_path,\n        \"fragment_hashes_by_slug\": fragment_hashes_by_slug,\n        \"collected\": collected,\n    }\n\n\n@pytest.mark.parametrize(\n    \"fragment_refs,expected\",\n    (\n        (\n            [\"hash1\"],\n            [\n                {\n                    \"name\": \"single_prompt_fragment\",\n                    \"prompt_fragments\": [\"hash1\"],\n                    \"system_fragments\": [],\n                },\n                {\n                    \"name\": \"multi_prompt_fragment\",\n                    \"prompt_fragments\": [\"hash1\", \"hash2\"],\n                    \"system_fragments\": [],\n                },\n                {\n                    \"name\": \"multi_system_fragment\",\n                    \"prompt_fragments\": [],\n                    \"system_fragments\": [\"hash1\", \"hash2\"],\n                },\n                {\n                    \"name\": \"both_fragments\",\n                    \"prompt_fragments\": [\"hash1\", \"hash2\"],\n                    \"system_fragments\": [\"hash3\", \"hash4\"],\n                },\n            ],\n        ),\n        (\n            [\"alias_3\"],\n            [\n                {\n                    \"name\": \"both_fragments\",\n                    \"prompt_fragments\": [\"hash1\", \"hash2\"],\n                    \"system_fragments\": [\"hash3\", \"hash4\"],\n                },\n                {\n                    \"name\": \"single_system_fragment_with_alias\",\n                    \"prompt_fragments\": [],\n                    \"system_fragments\": [\"hash4\"],\n                },\n            ],\n        ),\n        # Testing for AND condition\n        (\n            [\"hash1\", \"hash4\"],\n            [\n                {\n                    \"name\": \"both_fragments\",\n                    \"prompt_fragments\": [\"hash1\", \"hash2\"],\n                    \"system_fragments\": [\"hash3\", \"hash4\"],\n                },\n            ],\n        ),\n    ),\n)\ndef test_logs_fragments(fragments_fixture, fragment_refs, expected):\n    fragments_log_path = fragments_fixture[\"path\"]\n    fragment_hashes_by_slug = fragments_fixture[\"fragment_hashes_by_slug\"]\n    runner = CliRunner()\n    args = [\"logs\", \"-d\", fragments_log_path, \"-n\", \"0\"]\n    for ref in fragment_refs:\n        args.extend([\"-f\", ref])\n    result = runner.invoke(cli, args + [\"--json\"], catch_exceptions=False)\n    assert result.exit_code == 0\n    output = result.output\n    responses = json.loads(output)\n    # Re-shape that to same shape as expected\n    reshaped = [\n        {\n            \"name\": response[\"prompt\"].replace(\"prompt: \", \"\"),\n            \"prompt_fragments\": [\n                fragment[\"hash\"] for fragment in response[\"prompt_fragments\"]\n            ],\n            \"system_fragments\": [\n                fragment[\"hash\"] for fragment in response[\"system_fragments\"]\n            ],\n        }\n        for response in responses\n    ]\n    # Replace aliases with hash IDs in expected\n    for item in expected:\n        item[\"prompt_fragments\"] = [\n            fragment_hashes_by_slug.get(ref, ref) for ref in item[\"prompt_fragments\"]\n        ]\n        item[\"system_fragments\"] = [\n            fragment_hashes_by_slug.get(ref, ref) for ref in item[\"system_fragments\"]\n        ]\n    assert reshaped == expected\n    # Now test the `-s/--short` option:\n    result2 = runner.invoke(cli, args + [\"-s\"], catch_exceptions=False)\n    assert result2.exit_code == 0\n    output2 = result2.output\n    loaded = yaml.safe_load(output2)\n    reshaped2 = [\n        {\n            \"name\": item[\"prompt\"].replace(\"prompt: \", \"\"),\n            \"system_fragments\": item[\"system_fragments\"],\n            \"prompt_fragments\": item[\"prompt_fragments\"],\n        }\n        for item in loaded\n    ]\n    assert reshaped2 == expected\n\n\ndef test_logs_fragments_markdown(fragments_fixture):\n    fragments_log_path = fragments_fixture[\"path\"]\n    fragment_hashes_by_slug = fragments_fixture[\"fragment_hashes_by_slug\"]\n    runner = CliRunner()\n    args = [\"logs\", \"-d\", fragments_log_path, \"-n\", \"0\"]\n    result = runner.invoke(cli, args, catch_exceptions=False)\n    assert result.exit_code == 0\n    output = result.output\n    # Replace dates and IDs\n    output = datetime_re.sub(\"YYYY-MM-DDTHH:MM:SS\", output)\n    output = id_re.sub(\"id: xxx\", output)\n    expected_output = \"\"\"\n# YYYY-MM-DDTHH:MM:SS    conversation: abc123 id: xxx\n\nModel: **davinci**\n\n## Prompt\n\nprompt: no_fragments\n\n## System\n\nsystem: no_fragments\n\n## Response\n\nresponse: no_fragments\n\n# YYYY-MM-DDTHH:MM:SS    conversation: abc123 id: xxx\n\nModel: **davinci**\n\n## Prompt\n\nprompt: single_prompt_fragment\n\n### Prompt fragments\n\n- hash1\n\n## System\n\nsystem: single_prompt_fragment\n\n## Response\n\nresponse: single_prompt_fragment\n\n# YYYY-MM-DDTHH:MM:SS    conversation: abc123 id: xxx\n\nModel: **davinci**\n\n## Prompt\n\nprompt: single_system_fragment\n\n## System\n\nsystem: single_system_fragment\n\n### System fragments\n\n- hash2\n\n## Response\n\nresponse: single_system_fragment\n\n# YYYY-MM-DDTHH:MM:SS    conversation: abc123 id: xxx\n\nModel: **davinci**\n\n## Prompt\n\nprompt: multi_prompt_fragment\n\n### Prompt fragments\n\n- hash1\n- hash2\n\n## System\n\nsystem: multi_prompt_fragment\n\n## Response\n\nresponse: multi_prompt_fragment\n\n# YYYY-MM-DDTHH:MM:SS    conversation: abc123 id: xxx\n\nModel: **davinci**\n\n## Prompt\n\nprompt: multi_system_fragment\n\n## System\n\nsystem: multi_system_fragment\n\n### System fragments\n\n- hash1\n- hash2\n\n## Response\n\nresponse: multi_system_fragment\n\n# YYYY-MM-DDTHH:MM:SS    conversation: abc123 id: xxx\n\nModel: **davinci**\n\n## Prompt\n\nprompt: both_fragments\n\n### Prompt fragments\n\n- hash1\n- hash2\n\n## System\n\nsystem: both_fragments\n\n### System fragments\n\n- hash3\n- hash4\n\n## Response\n\nresponse: both_fragments\n\n# YYYY-MM-DDTHH:MM:SS    conversation: abc123 id: xxx\n\nModel: **davinci**\n\n## Prompt\n\nprompt: single_long_prompt_fragment_with_alias\n\n### Prompt fragments\n\n- hash5\n\n## System\n\nsystem: single_long_prompt_fragment_with_alias\n\n## Response\n\nresponse: single_long_prompt_fragment_with_alias\n\n# YYYY-MM-DDTHH:MM:SS    conversation: abc123 id: xxx\n\nModel: **davinci**\n\n## Prompt\n\nprompt: single_system_fragment_with_alias\n\n## System\n\nsystem: single_system_fragment_with_alias\n\n### System fragments\n\n- hash4\n\n## Response\n\nresponse: single_system_fragment_with_alias\n    \"\"\"\n    # Replace hash4 etc with their proper IDs\n    for key, value in fragment_hashes_by_slug.items():\n        expected_output = expected_output.replace(key, value)\n    assert output.strip() == expected_output.strip()\n\n\n@pytest.mark.parametrize(\"arg\", (\"-e\", \"--expand\"))\ndef test_expand_fragment_json(fragments_fixture, arg):\n    fragments_log_path = fragments_fixture[\"path\"]\n    runner = CliRunner()\n    args = [\"logs\", \"-d\", fragments_log_path, \"-f\", \"long_5\", \"--json\"]\n    # Without -e the JSON is truncated\n    result = runner.invoke(cli, args, catch_exceptions=False)\n    assert result.exit_code == 0\n    data = json.loads(result.output)\n    fragment = data[0][\"prompt_fragments\"][0][\"content\"]\n    assert fragment.startswith(\"This is fragment 5This is fragment 5\")\n    assert len(fragment) < 200\n    # With -e the JSON is expanded\n    result2 = runner.invoke(cli, args + [arg], catch_exceptions=False)\n    assert result2.exit_code == 0\n    data2 = json.loads(result2.output)\n    fragment2 = data2[0][\"prompt_fragments\"][0][\"content\"]\n    assert fragment2.startswith(\"This is fragment 5This is fragment 5\")\n    assert len(fragment2) > 200\n\n\ndef test_expand_fragment_markdown(fragments_fixture):\n    fragments_log_path = fragments_fixture[\"path\"]\n    fragment_hashes_by_slug = fragments_fixture[\"fragment_hashes_by_slug\"]\n    runner = CliRunner()\n    args = [\"logs\", \"-d\", fragments_log_path, \"-f\", \"long_5\", \"--expand\"]\n    result = runner.invoke(cli, args, catch_exceptions=False)\n    assert result.exit_code == 0\n    output = result.output\n    interesting_bit = (\n        output.split(\"prompt: single_long_prompt_fragment_with_alias\")[1]\n        .split(\"## System\")[0]\n        .strip()\n    )\n    hash = fragment_hashes_by_slug[\"hash5\"]\n    expected_prefix = f\"### Prompt fragments\\n\\n<details><summary>{hash}</summary>\\nThis is fragment 5\"\n    assert interesting_bit.startswith(expected_prefix)\n    assert interesting_bit.endswith(\"</details>\")\n\n\ndef test_logs_tools(logs_db):\n    runner = CliRunner()\n    code = textwrap.dedent(\"\"\"\n    def demo():\n        return \"one\\\\ntwo\\\\nthree\"\n    \"\"\")\n    result1 = runner.invoke(\n        cli,\n        [\n            \"-m\",\n            \"echo\",\n            \"--functions\",\n            code,\n            json.dumps({\"tool_calls\": [{\"name\": \"demo\"}]}),\n        ],\n    )\n    assert result1.exit_code == 0\n    result2 = runner.invoke(cli, [\"logs\", \"-c\"])\n    assert (\n        \"### Tool results\\n\"\n        \"\\n\"\n        \"- **demo**: `None`<br>\\n\"\n        \"    one\\n\"\n        \"    two\\n\"\n        \"    three\\n\"\n        \"\\n\"\n    ) in result2.output\n    # Log one that did NOT use tools, check that `llm logs --tools` ignores it\n    assert runner.invoke(cli, [\"-m\", \"echo\", \"badger\"]).exit_code == 0\n    assert \"badger\" in runner.invoke(cli, [\"logs\"]).output\n    logs_tools_output = runner.invoke(cli, [\"logs\", \"--tools\"]).output\n    assert \"badger\" not in logs_tools_output\n    assert \"three\" in logs_tools_output\n\n\ndef test_logs_backup(logs_db):\n    assert not logs_db.tables\n    runner = CliRunner()\n    with runner.isolated_filesystem():\n        runner.invoke(cli, [\"-m\", \"echo\", \"simple prompt\"])\n        assert logs_db.tables\n        expected_path = pathlib.Path(\"backup.db\")\n        assert not expected_path.exists()\n        # Now back it up\n        result = runner.invoke(cli, [\"logs\", \"backup\", \"backup.db\"])\n        assert result.exit_code == 0\n        assert result.output.startswith(\"Backed up \")\n        assert result.output.endswith(\"to backup.db\\n\")\n        assert expected_path.exists()\n\n\n@pytest.mark.parametrize(\"async_\", (False, True))\ndef test_logs_resolved_model(logs_db, mock_model, async_mock_model, async_):\n    mock_model.resolved_model_name = \"resolved-mock\"\n    async_mock_model.resolved_model_name = \"resolved-mock\"\n    runner = CliRunner()\n    result = runner.invoke(\n        cli, [\"-m\", \"mock\", \"simple prompt\"] + ([\"--async\"] if async_ else [])\n    )\n    assert result.exit_code == 0\n    # Should have logged the resolved model name\n    assert logs_db[\"responses\"].count\n    response = list(logs_db[\"responses\"].rows)[0]\n    assert response[\"model\"] == \"mock\"\n    assert response[\"resolved_model\"] == \"resolved-mock\"\n\n    # Should show up in the JSON logs\n    result2 = runner.invoke(cli, [\"logs\", \"--json\"])\n    assert result2.exit_code == 0\n    logs = json.loads(result2.output.strip())\n    assert len(logs) == 1\n    assert logs[0][\"model\"] == \"mock\"\n    assert logs[0][\"resolved_model\"] == \"resolved-mock\"\n\n    # And the rendered logs\n    result3 = runner.invoke(cli, [\"logs\"])\n    assert \"Model: **mock** (resolved: **resolved-mock**)\" in result3.output\n"
  },
  {
    "path": "tests/test_migrate.py",
    "content": "import llm\nfrom llm.migrations import migrate\nfrom llm.embeddings_migrations import embeddings_migrations\nimport pytest\nimport sqlite_utils\n\nEXPECTED = {\n    \"id\": str,\n    \"model\": str,\n    \"resolved_model\": str,\n    \"prompt\": str,\n    \"system\": str,\n    \"prompt_json\": str,\n    \"options_json\": str,\n    \"response\": str,\n    \"response_json\": str,\n    \"conversation_id\": str,\n    \"duration_ms\": int,\n    \"datetime_utc\": str,\n    \"input_tokens\": int,\n    \"output_tokens\": int,\n    \"token_details\": str,\n    \"schema_id\": str,\n}\n\n\ndef test_migrate_blank():\n    db = sqlite_utils.Database(memory=True)\n    migrate(db)\n    assert set(db.table_names()).issuperset(\n        {\"_llm_migrations\", \"conversations\", \"responses\", \"responses_fts\"}\n    )\n    assert db[\"responses\"].columns_dict == EXPECTED\n\n    foreign_keys = db[\"responses\"].foreign_keys\n    for expected_fk in (\n        sqlite_utils.db.ForeignKey(\n            table=\"responses\",\n            column=\"conversation_id\",\n            other_table=\"conversations\",\n            other_column=\"id\",\n        ),\n    ):\n        assert expected_fk in foreign_keys\n\n    # Should have FTS configured with triggers on correct tables\n    assert {trigger.name for trigger in db.triggers} == {\n        \"responses_ai\",\n        \"responses_ad\",\n        \"responses_au\",\n    }\n\n\n@pytest.mark.parametrize(\"has_record\", [True, False])\ndef test_migrate_from_original_schema(has_record):\n    db = sqlite_utils.Database(memory=True)\n    if has_record:\n        db[\"log\"].insert(\n            {\n                \"provider\": \"provider\",\n                \"system\": \"system\",\n                \"prompt\": \"prompt\",\n                \"chat_id\": None,\n                \"response\": \"response\",\n                \"model\": \"model\",\n                \"timestamp\": \"timestamp\",\n            },\n        )\n    else:\n        # Create empty logs table\n        db[\"log\"].create(\n            {\n                \"provider\": str,\n                \"system\": str,\n                \"prompt\": str,\n                \"chat_id\": str,\n                \"response\": str,\n                \"model\": str,\n                \"timestamp\": str,\n            }\n        )\n    migrate(db)\n    expected_tables = {\"_llm_migrations\", \"conversations\", \"responses\", \"responses_fts\"}\n    if has_record:\n        expected_tables.add(\"logs\")\n    assert set(db.table_names()).issuperset(expected_tables)\n    assert {trigger.name for trigger in db.triggers} == {\n        \"responses_ai\",\n        \"responses_ad\",\n        \"responses_au\",\n    }\n\n\ndef test_migrations_with_legacy_alter_table():\n    # https://github.com/simonw/llm/issues/162\n    db = sqlite_utils.Database(memory=True)\n    db.execute(\"pragma legacy_alter_table=on\")\n    migrate(db)\n\n\ndef test_migrations_for_embeddings():\n    db = sqlite_utils.Database(memory=True)\n    embeddings_migrations.apply(db)\n    assert db[\"collections\"].columns_dict == {\"id\": int, \"name\": str, \"model\": str}\n    assert db[\"embeddings\"].columns_dict == {\n        \"collection_id\": int,\n        \"id\": str,\n        \"embedding\": bytes,\n        \"content\": str,\n        \"content_blob\": bytes,\n        \"content_hash\": bytes,\n        \"metadata\": str,\n        \"updated\": int,\n    }\n    assert db[\"embeddings\"].foreign_keys[0].column == \"collection_id\"\n    assert db[\"embeddings\"].foreign_keys[0].other_table == \"collections\"\n\n\ndef test_backfill_content_hash():\n    db = sqlite_utils.Database(memory=True)\n    # Run migrations up to but not including m004_store_content_hash\n    embeddings_migrations.apply(db, stop_before=\"m004_store_content_hash\")\n    assert \"content_hash\" not in db[\"embeddings\"].columns_dict\n    # Add some some directly directly because llm.Collection would run migrations\n    db[\"embeddings\"].insert_all(\n        [\n            {\n                \"collection_id\": 1,\n                \"id\": \"1\",\n                \"embedding\": (\n                    b\"\\x00\\x00\\xa0@\\x00\\x00\\xa0@\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                    b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                    b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                    b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                ),\n                \"content\": None,\n                \"metadata\": None,\n                \"updated\": 1693763088,\n            },\n            {\n                \"collection_id\": 1,\n                \"id\": \"2\",\n                \"embedding\": (\n                    b\"\\x00\\x00\\xe0@\\x00\\x00\\xa0@\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                    b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                    b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                    b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\"\n                ),\n                \"content\": \"goodbye world\",\n                \"metadata\": None,\n                \"updated\": 1693763088,\n            },\n        ]\n    )\n    # Now finish the migrations\n    embeddings_migrations.apply(db)\n    row1, row2 = db[\"embeddings\"].rows\n    # This one should be random:\n    assert row1[\"content_hash\"] is not None\n    # This should be a hash of 'goodbye world'\n    assert row2[\"content_hash\"] == llm.Collection.content_hash(\"goodbye world\")\n"
  },
  {
    "path": "tests/test_plugins.py",
    "content": "from click.testing import CliRunner\nimport click\nimport importlib\nimport json\nimport llm\nfrom llm.tools import llm_version, llm_time\nfrom llm import cli, hookimpl, plugins, get_template_loaders, get_fragment_loaders\nimport pathlib\nimport pytest\nimport textwrap\n\n\ndef test_register_commands():\n    importlib.reload(cli)\n\n    def plugin_names():\n        return [plugin[\"name\"] for plugin in llm.get_plugins()]\n\n    assert \"HelloWorldPlugin\" not in plugin_names()\n\n    class HelloWorldPlugin:\n        __name__ = \"HelloWorldPlugin\"\n\n        @hookimpl\n        def register_commands(self, cli):\n            @cli.command(name=\"hello-world\")\n            def hello_world():\n                \"Print hello world\"\n                click.echo(\"Hello world!\")\n\n    try:\n        plugins.pm.register(HelloWorldPlugin(), name=\"HelloWorldPlugin\")\n        importlib.reload(cli)\n\n        assert \"HelloWorldPlugin\" in plugin_names()\n\n        runner = CliRunner()\n        result = runner.invoke(cli.cli, [\"hello-world\"])\n        assert result.exit_code == 0\n        assert result.output == \"Hello world!\\n\"\n\n    finally:\n        plugins.pm.unregister(name=\"HelloWorldPlugin\")\n        importlib.reload(cli)\n        assert \"HelloWorldPlugin\" not in plugin_names()\n\n\ndef test_register_template_loaders():\n    assert get_template_loaders() == {}\n\n    def one_loader(template_path):\n        return llm.Template(name=\"one:\" + template_path, prompt=template_path)\n\n    def two_loader(template_path):\n        \"Docs for two\"\n        return llm.Template(name=\"two:\" + template_path, prompt=template_path)\n\n    def dupe_two_loader(template_path):\n        \"Docs for two dupe\"\n        return llm.Template(name=\"two:\" + template_path, prompt=template_path)\n\n    class TemplateLoadersPlugin:\n        __name__ = \"TemplateLoadersPlugin\"\n\n        @hookimpl\n        def register_template_loaders(self, register):\n            register(\"one\", one_loader)\n            register(\"two\", two_loader)\n            register(\"two\", dupe_two_loader)\n\n    try:\n        plugins.pm.register(TemplateLoadersPlugin(), name=\"TemplateLoadersPlugin\")\n        loaders = get_template_loaders()\n        assert loaders == {\n            \"one\": one_loader,\n            \"two\": two_loader,\n            \"two_1\": dupe_two_loader,\n        }\n\n        # Test the CLI command\n        runner = CliRunner()\n        result = runner.invoke(cli.cli, [\"templates\", \"loaders\"])\n        assert result.exit_code == 0\n        assert result.output == (\n            \"one:\\n\"\n            \"  Undocumented\\n\"\n            \"two:\\n\"\n            \"  Docs for two\\n\"\n            \"two_1:\\n\"\n            \"  Docs for two dupe\\n\"\n        )\n\n    finally:\n        plugins.pm.unregister(name=\"TemplateLoadersPlugin\")\n        assert get_template_loaders() == {}\n\n\ndef test_register_fragment_loaders(logs_db, httpx_mock):\n    httpx_mock.add_response(\n        method=\"HEAD\",\n        url=\"https://example.com/attachment.png\",\n        content=b\"attachment\",\n        headers={\"Content-Type\": \"image/png\"},\n        is_reusable=True,\n    )\n\n    assert get_fragment_loaders() == {}\n\n    def single_fragment(argument):\n        \"This is the fragment documentation\"\n        return llm.Fragment(\"single\", \"single\")\n\n    def three_fragments(argument):\n        return [\n            llm.Fragment(f\"one:{argument}\", \"one\"),\n            llm.Fragment(f\"two:{argument}\", \"two\"),\n            llm.Fragment(f\"three:{argument}\", \"three\"),\n        ]\n\n    def fragment_and_attachment(argument):\n        return [\n            llm.Fragment(f\"one:{argument}\", \"one\"),\n            llm.Attachment(url=\"https://example.com/attachment.png\"),\n        ]\n\n    class FragmentLoadersPlugin:\n        __name__ = \"FragmentLoadersPlugin\"\n\n        @hookimpl\n        def register_fragment_loaders(self, register):\n            register(\"single\", single_fragment)\n            register(\"three\", three_fragments)\n            register(\"mixed\", fragment_and_attachment)\n\n    try:\n        plugins.pm.register(FragmentLoadersPlugin(), name=\"FragmentLoadersPlugin\")\n        loaders = get_fragment_loaders()\n        assert loaders == {\n            \"single\": single_fragment,\n            \"three\": three_fragments,\n            \"mixed\": fragment_and_attachment,\n        }\n\n        # Test the CLI command\n        runner = CliRunner()\n        result = runner.invoke(\n            cli.cli, [\"-m\", \"echo\", \"-f\", \"three:x\"], catch_exceptions=False\n        )\n        assert result.exit_code == 0\n        assert json.loads(result.output) == {\n            \"prompt\": \"one:x\\ntwo:x\\nthree:x\",\n            \"system\": \"\",\n            \"attachments\": [],\n            \"stream\": True,\n            \"previous\": [],\n        }\n        # And the llm fragments loaders command:\n        result2 = runner.invoke(cli.cli, [\"fragments\", \"loaders\"])\n        assert result2.exit_code == 0\n        expected2 = (\n            \"single:\\n\"\n            \"  This is the fragment documentation\\n\"\n            \"\\n\"\n            \"three:\\n\"\n            \"  Undocumented\\n\"\n            \"\\n\"\n            \"mixed:\\n\"\n            \"  Undocumented\\n\"\n        )\n        assert result2.output == expected2\n\n        # Test the one that includes an attachment\n        result3 = runner.invoke(\n            cli.cli, [\"-m\", \"echo\", \"-f\", \"mixed:x\"], catch_exceptions=False\n        )\n        assert result3.exit_code == 0\n        result3.output.strip == textwrap.dedent(\"\"\"\\\n            system:\n\n\n            prompt:\n            one:x\n\n            attachments:\n            - https://example.com/attachment.png\n            \"\"\").strip()\n\n    finally:\n        plugins.pm.unregister(name=\"FragmentLoadersPlugin\")\n        assert get_fragment_loaders() == {}\n\n    # Let's check the database\n    assert list(logs_db.query(\"select content, source from fragments\")) == [\n        {\"content\": \"one:x\", \"source\": \"one\"},\n        {\"content\": \"two:x\", \"source\": \"two\"},\n        {\"content\": \"three:x\", \"source\": \"three\"},\n    ]\n\n\ndef test_register_tools(tmpdir, logs_db):\n    def upper(text: str) -> str:\n        \"\"\"Convert text to uppercase.\"\"\"\n        return text.upper()\n\n    def count_character_in_word(text: str, character: str) -> int:\n        \"\"\"Count the number of occurrences of a character in a word.\"\"\"\n        return text.count(character)\n\n    def output_as_json(text: str):\n        return {\"this_is_in_json\": {\"nested\": text}}\n\n    class ToolsPlugin:\n        __name__ = \"ToolsPlugin\"\n\n        @hookimpl\n        def register_tools(self, register):\n            register(llm.Tool.function(upper))\n            register(count_character_in_word, name=\"count_chars\")\n            register(output_as_json)\n\n    try:\n        plugins.pm.register(ToolsPlugin(), name=\"ToolsPlugin\")\n        tools = llm.get_tools()\n        assert tools == {\n            \"upper\": llm.Tool(\n                name=\"upper\",\n                description=\"Convert text to uppercase.\",\n                input_schema={\n                    \"properties\": {\"text\": {\"type\": \"string\"}},\n                    \"required\": [\"text\"],\n                    \"type\": \"object\",\n                },\n                implementation=upper,\n                plugin=\"ToolsPlugin\",\n            ),\n            \"count_chars\": llm.Tool(\n                name=\"count_chars\",\n                description=\"Count the number of occurrences of a character in a word.\",\n                input_schema={\n                    \"properties\": {\n                        \"text\": {\"type\": \"string\"},\n                        \"character\": {\"type\": \"string\"},\n                    },\n                    \"required\": [\"text\", \"character\"],\n                    \"type\": \"object\",\n                },\n                implementation=count_character_in_word,\n                plugin=\"ToolsPlugin\",\n            ),\n            \"llm_version\": llm.Tool(\n                name=\"llm_version\",\n                description=\"Return the installed version of llm\",\n                input_schema={\"properties\": {}, \"type\": \"object\"},\n                implementation=llm_version,\n                plugin=\"llm.default_plugins.default_tools\",\n            ),\n            \"output_as_json\": llm.Tool(\n                name=\"output_as_json\",\n                description=None,\n                input_schema={\n                    \"properties\": {\"text\": {\"type\": \"string\"}},\n                    \"required\": [\"text\"],\n                    \"type\": \"object\",\n                },\n                implementation=output_as_json,\n                plugin=\"ToolsPlugin\",\n            ),\n            \"llm_time\": llm.Tool(\n                name=\"llm_time\",\n                description=\"Returns the current time, as local time and UTC\",\n                input_schema={\"properties\": {}, \"type\": \"object\"},\n                implementation=llm_time,\n                plugin=\"llm.default_plugins.default_tools\",\n            ),\n        }\n\n        # Test the CLI command\n        runner = CliRunner()\n        result = runner.invoke(cli.cli, [\"tools\", \"list\"])\n        assert result.exit_code == 0\n        assert result.output == (\n            \"count_chars(text: str, character: str) -> int (plugin: ToolsPlugin)\\n\\n\"\n            \"  Count the number of occurrences of a character in a word.\\n\\n\"\n            \"llm_time() -> dict (plugin: llm.default_plugins.default_tools)\\n\\n\"\n            \"  Returns the current time, as local time and UTC\\n\\n\"\n            \"llm_version() -> str (plugin: llm.default_plugins.default_tools)\\n\\n\"\n            \"  Return the installed version of llm\\n\\n\"\n            \"output_as_json(text: str) (plugin: ToolsPlugin)\\n\\n\"\n            \"upper(text: str) -> str (plugin: ToolsPlugin)\\n\\n\"\n            \"  Convert text to uppercase.\\n\\n\"\n        )\n        # And --json\n        result2 = runner.invoke(cli.cli, [\"tools\", \"list\", \"--json\"])\n        assert result2.exit_code == 0\n        assert json.loads(result2.output) == {\n            \"tools\": [\n                {\n                    \"name\": \"count_chars\",\n                    \"description\": \"Count the number of occurrences of a character in a word.\",\n                    \"arguments\": {\n                        \"properties\": {\n                            \"text\": {\"type\": \"string\"},\n                            \"character\": {\"type\": \"string\"},\n                        },\n                        \"required\": [\"text\", \"character\"],\n                        \"type\": \"object\",\n                    },\n                    \"plugin\": \"ToolsPlugin\",\n                },\n                {\n                    \"arguments\": {\n                        \"properties\": {},\n                        \"type\": \"object\",\n                    },\n                    \"description\": \"Returns the current time, as local time and UTC\",\n                    \"name\": \"llm_time\",\n                    \"plugin\": \"llm.default_plugins.default_tools\",\n                },\n                {\n                    \"name\": \"llm_version\",\n                    \"description\": \"Return the installed version of llm\",\n                    \"arguments\": {\"properties\": {}, \"type\": \"object\"},\n                    \"plugin\": \"llm.default_plugins.default_tools\",\n                },\n                {\n                    \"name\": \"output_as_json\",\n                    \"description\": None,\n                    \"arguments\": {\n                        \"properties\": {\"text\": {\"type\": \"string\"}},\n                        \"required\": [\"text\"],\n                        \"type\": \"object\",\n                    },\n                    \"plugin\": \"ToolsPlugin\",\n                },\n                {\n                    \"name\": \"upper\",\n                    \"description\": \"Convert text to uppercase.\",\n                    \"arguments\": {\n                        \"properties\": {\"text\": {\"type\": \"string\"}},\n                        \"required\": [\"text\"],\n                        \"type\": \"object\",\n                    },\n                    \"plugin\": \"ToolsPlugin\",\n                },\n            ],\n            \"toolboxes\": [],\n        }\n\n        # And test the --tools option\n        functions_path = str(tmpdir / \"functions.py\")\n        with open(functions_path, \"w\") as fp:\n            fp.write(\"def example(s: str, i: int):\\n    return s + '-' + str(i)\")\n        result3 = runner.invoke(\n            cli.cli,\n            [\n                \"tools\",\n                \"--functions\",\n                \"def reverse(s: str): return s[::-1]\",\n                \"--functions\",\n                functions_path,\n            ],\n        )\n        assert result3.exit_code == 0\n        assert \"reverse(s: str)\" in result3.output\n        assert \"example(s: str, i: int)\" in result3.output\n        # Now run a prompt using a plugin tool and to check it gets logged correctly\n        result4 = runner.invoke(\n            cli.cli,\n            [\n                \"-m\",\n                \"echo\",\n                \"--tool\",\n                \"upper\",\n                json.dumps(\n                    {\"tool_calls\": [{\"name\": \"upper\", \"arguments\": {\"text\": \"hi\"}}]}\n                ),\n            ],\n            catch_exceptions=False,\n        )\n        assert result4.exit_code == 0\n        assert '\"output\": \"HI\"' in result4.output\n\n        # Now check in the database\n        tool_row = [row for row in logs_db[\"tools\"].rows][0]\n        assert tool_row[\"name\"] == \"upper\"\n        assert tool_row[\"plugin\"] == \"ToolsPlugin\"\n\n        # The llm logs command should return that, including with the -T upper option\n        for args in ([], [\"-T\", \"upper\"]):\n            logs_result = runner.invoke(cli.cli, [\"logs\"] + args)\n            assert logs_result.exit_code == 0\n            assert \"HI\" in logs_result.output\n        # ... but not for -T reverse\n        logs_empty_result = runner.invoke(cli.cli, [\"logs\", \"-T\", \"count_chars\"])\n        assert logs_empty_result.exit_code == 0\n        assert \"HI\" not in logs_empty_result.output\n\n        # Start with a tool, use llm -c to reuse the same tool\n        result5 = runner.invoke(\n            cli.cli,\n            [\n                \"prompt\",\n                \"-m\",\n                \"echo\",\n                \"--tool\",\n                \"upper\",\n                json.dumps(\n                    {\"tool_calls\": [{\"name\": \"upper\", \"arguments\": {\"text\": \"one\"}}]}\n                ),\n            ],\n        )\n        assert result5.exit_code == 0\n        assert (\n            runner.invoke(\n                cli.cli,\n                [\n                    \"-c\",\n                    json.dumps(\n                        {\n                            \"tool_calls\": [\n                                {\"name\": \"upper\", \"arguments\": {\"text\": \"two\"}}\n                            ]\n                        }\n                    ),\n                ],\n            ).exit_code\n            == 0\n        )\n        # Now do it again with llm chat -c\n        assert (\n            runner.invoke(\n                cli.cli,\n                [\"chat\", \"-c\"],\n                input=(\n                    json.dumps(\n                        {\n                            \"tool_calls\": [\n                                {\"name\": \"upper\", \"arguments\": {\"text\": \"three\"}}\n                            ]\n                        }\n                    )\n                    + \"\\nquit\\n\"\n                ),\n                catch_exceptions=False,\n            ).exit_code\n            == 0\n        )\n        # Should have logged those three tool uses in llm logs -c -n 0\n        log_rows = json.loads(\n            runner.invoke(cli.cli, [\"logs\", \"-c\", \"-n\", \"0\", \"--json\"]).output\n        )\n        results = tuple(\n            (log_row[\"prompt\"], json.dumps(log_row[\"tool_results\"]))\n            for log_row in log_rows\n        )\n        assert results == (\n            ('{\"tool_calls\": [{\"name\": \"upper\", \"arguments\": {\"text\": \"one\"}}]}', \"[]\"),\n            (\n                \"\",\n                '[{\"id\": 2, \"tool_id\": 1, \"name\": \"upper\", \"output\": \"ONE\", \"tool_call_id\": null, \"exception\": null, \"attachments\": []}]',\n            ),\n            ('{\"tool_calls\": [{\"name\": \"upper\", \"arguments\": {\"text\": \"two\"}}]}', \"[]\"),\n            (\n                \"\",\n                '[{\"id\": 3, \"tool_id\": 1, \"name\": \"upper\", \"output\": \"TWO\", \"tool_call_id\": null, \"exception\": null, \"attachments\": []}]',\n            ),\n            (\n                '{\"tool_calls\": [{\"name\": \"upper\", \"arguments\": {\"text\": \"three\"}}]}',\n                \"[]\",\n            ),\n            (\n                \"\",\n                '[{\"id\": 4, \"tool_id\": 1, \"name\": \"upper\", \"output\": \"THREE\", \"tool_call_id\": null, \"exception\": null, \"attachments\": []}]',\n            ),\n        )\n        # Test the --td option\n        result6 = runner.invoke(\n            cli.cli,\n            [\n                \"prompt\",\n                \"-m\",\n                \"echo\",\n                \"--tool\",\n                \"output_as_json\",\n                json.dumps(\n                    {\n                        \"tool_calls\": [\n                            {\"name\": \"output_as_json\", \"arguments\": {\"text\": \"hi\"}}\n                        ]\n                    }\n                ),\n                \"--td\",\n            ],\n        )\n        assert result6.exit_code == 0\n        assert (\n            \"Tool call: output_as_json({'text': 'hi'})\\n\"\n            \"  {\\n\"\n            '    \"this_is_in_json\": {\\n'\n            '      \"nested\": \"hi\"\\n'\n            \"    }\\n\"\n            \"  }\"\n        ) in result6.output\n    finally:\n        plugins.pm.unregister(name=\"ToolsPlugin\")\n\n\nclass Memory(llm.Toolbox):\n    _memory = None\n\n    def _get_memory(self):\n        if self._memory is None:\n            self._memory = {}\n        return self._memory\n\n    def set(self, key: str, value: str):\n        \"Set something as a key\"\n        self._get_memory()[key] = value\n\n    def get(self, key: str):\n        \"Get something from a key\"\n        return self._get_memory().get(key) or \"\"\n\n    def append(self, key: str, value: str):\n        \"Append something as a key\"\n        memory = self._get_memory()\n        memory[key] = (memory.get(key) or \"\") + \"\\n\" + value\n\n    def keys(self):\n        \"Return a list of keys\"\n        return list(self._get_memory().keys())\n\n\nclass Filesystem(llm.Toolbox):\n    def __init__(self, path: str):\n        self.path = path\n\n    async def list_files(self):\n        # async here just to confirm that works\n        return [str(item) for item in pathlib.Path(self.path).glob(\"*\")]\n\n\nclass ToolboxPlugin:\n    __name__ = \"ToolboxPlugin\"\n\n    @hookimpl\n    def register_tools(self, register):\n        register(Memory)\n        register(Filesystem)\n\n\ndef test_register_toolbox(tmpdir, logs_db):\n    # Test the Python API\n    model = llm.get_model(\"echo\")\n    memory = Memory()\n    conversation = model.conversation(tools=[memory])\n    accumulated = []\n\n    def after_call(tool, tool_call, tool_result):\n        accumulated.append((tool.name, tool_call.arguments, tool_result.output))\n\n    conversation.chain(\n        json.dumps(\n            {\n                \"tool_calls\": [\n                    {\n                        \"name\": \"Memory_set\",\n                        \"arguments\": {\"key\": \"hello\", \"value\": \"world\"},\n                    }\n                ]\n            }\n        ),\n        after_call=after_call,\n    ).text()\n    conversation.chain(\n        json.dumps(\n            {\"tool_calls\": [{\"name\": \"Memory_get\", \"arguments\": {\"key\": \"hello\"}}]}\n        ),\n        after_call=after_call,\n    ).text()\n    assert accumulated == [\n        (\"Memory_set\", {\"key\": \"hello\", \"value\": \"world\"}, \"null\"),\n        (\"Memory_get\", {\"key\": \"hello\"}, \"world\"),\n    ]\n    assert memory._memory == {\"hello\": \"world\"}\n\n    # And for the Filesystem with state\n    my_dir = pathlib.Path(tmpdir / \"mine\")\n    my_dir.mkdir()\n    (my_dir / \"doc.txt\").write_text(\"hi\", \"utf-8\")\n    conversation = model.conversation(tools=[Filesystem(my_dir)])\n    accumulated.clear()\n    conversation.chain(\n        json.dumps(\n            {\n                \"tool_calls\": [\n                    {\n                        \"name\": \"Filesystem_list_files\",\n                    }\n                ]\n            }\n        ),\n        after_call=after_call,\n    ).text()\n    assert accumulated == [\n        (\"Filesystem_list_files\", {}, json.dumps([str(my_dir / \"doc.txt\")]))\n    ]\n\n    # Now register them with a plugin and use it through the CLI\n    try:\n        plugins.pm.register(ToolboxPlugin(), name=\"ToolboxPlugin\")\n        tools = llm.get_tools()\n        assert tools[\"Memory\"] is Memory\n\n        runner = CliRunner()\n        # llm tools --json\n        result = runner.invoke(cli.cli, [\"tools\", \"--json\"])\n        assert result.exit_code == 0\n        assert json.loads(result.output) == {\n            \"tools\": [\n                {\n                    \"description\": \"Returns the current time, as local time and UTC\",\n                    \"name\": \"llm_time\",\n                    \"plugin\": \"llm.default_plugins.default_tools\",\n                    \"arguments\": {\n                        \"properties\": {},\n                        \"type\": \"object\",\n                    },\n                },\n                {\n                    \"name\": \"llm_version\",\n                    \"description\": \"Return the installed version of llm\",\n                    \"arguments\": {\"properties\": {}, \"type\": \"object\"},\n                    \"plugin\": \"llm.default_plugins.default_tools\",\n                },\n            ],\n            \"toolboxes\": [\n                {\n                    \"name\": \"Filesystem\",\n                    \"tools\": [\n                        {\n                            \"name\": \"Filesystem_list_files\",\n                            \"description\": None,\n                            \"arguments\": {\"properties\": {}, \"type\": \"object\"},\n                        }\n                    ],\n                },\n                {\n                    \"name\": \"Memory\",\n                    \"tools\": [\n                        {\n                            \"name\": \"Memory_append\",\n                            \"description\": \"Append something as a key\",\n                            \"arguments\": {\n                                \"properties\": {\n                                    \"key\": {\"type\": \"string\"},\n                                    \"value\": {\"type\": \"string\"},\n                                },\n                                \"required\": [\"key\", \"value\"],\n                                \"type\": \"object\",\n                            },\n                        },\n                        {\n                            \"name\": \"Memory_get\",\n                            \"description\": \"Get something from a key\",\n                            \"arguments\": {\n                                \"properties\": {\"key\": {\"type\": \"string\"}},\n                                \"required\": [\"key\"],\n                                \"type\": \"object\",\n                            },\n                        },\n                        {\n                            \"name\": \"Memory_keys\",\n                            \"description\": \"Return a list of keys\",\n                            \"arguments\": {\"properties\": {}, \"type\": \"object\"},\n                        },\n                        {\n                            \"name\": \"Memory_set\",\n                            \"description\": \"Set something as a key\",\n                            \"arguments\": {\n                                \"properties\": {\n                                    \"key\": {\"type\": \"string\"},\n                                    \"value\": {\"type\": \"string\"},\n                                },\n                                \"required\": [\"key\", \"value\"],\n                                \"type\": \"object\",\n                            },\n                        },\n                    ],\n                },\n            ],\n        }\n\n        # llm tools (no JSON)\n        result = runner.invoke(cli.cli, [\"tools\"])\n        assert result.exit_code == 0\n        assert result.output == (\n            \"llm_time() -> dict (plugin: llm.default_plugins.default_tools)\\n\\n\"\n            \"  Returns the current time, as local time and UTC\\n\\n\"\n            \"llm_version() -> str (plugin: llm.default_plugins.default_tools)\\n\\n\"\n            \"  Return the installed version of llm\\n\\n\"\n            \"Filesystem:\\n\\n\"\n            \"  Filesystem_list_files()\\n\\n\"\n            \"Memory:\\n\\n\"\n            \"  Memory_append(key: str, value: str)\\n\\n\"\n            \"    Append something as a key\\n\\n\"\n            \"  Memory_get(key: str)\\n\\n\"\n            \"    Get something from a key\\n\\n\"\n            \"  Memory_keys()\\n\\n\"\n            \"    Return a list of keys\\n\\n\"\n            \"  Memory_set(key: str, value: str)\\n\\n\"\n            \"    Set something as a key\\n\\n\"\n        )\n\n        # Test the CLI running a toolbox prompt\n        result3 = runner.invoke(\n            cli.cli,\n            [\n                \"prompt\",\n                \"-T\",\n                \"Memory\",\n                json.dumps(\n                    {\n                        \"tool_calls\": [\n                            {\n                                \"name\": \"Memory_set\",\n                                \"arguments\": {\"key\": \"hi\", \"value\": \"two\"},\n                            },\n                            {\"name\": \"Memory_get\", \"arguments\": {\"key\": \"hi\"}},\n                        ]\n                    }\n                ),\n                \"-m\",\n                \"echo\",\n            ],\n        )\n        assert result3.exit_code == 0\n        tool_results = json.loads(\n            \"[\" + result3.output.split('\"tool_results\": [')[1].split(\"]\")[0] + \"]\"\n        )\n        assert tool_results == [\n            {\"name\": \"Memory_set\", \"output\": \"null\", \"tool_call_id\": None},\n            {\"name\": \"Memory_get\", \"output\": \"two\", \"tool_call_id\": None},\n        ]\n\n        # Test the CLI running a configured toolbox prompt\n        my_dir2 = pathlib.Path(tmpdir / \"mine2\")\n        my_dir2.mkdir()\n        other_path = my_dir2 / \"other.txt\"\n        other_path.write_text(\"hi\", \"utf-8\")\n        result4 = runner.invoke(\n            cli.cli,\n            [\n                \"prompt\",\n                \"-T\",\n                \"Filesystem({})\".format(json.dumps(str(my_dir2))),\n                json.dumps({\"tool_calls\": [{\"name\": \"Filesystem_list_files\"}]}),\n                \"-m\",\n                \"echo\",\n            ],\n        )\n        assert result4.exit_code == 0\n        tool_results = json.loads(\n            \"[\" + result4.output.split('\"tool_results\": [')[1].rsplit(\"]\", 1)[0] + \"]\"\n        )\n        assert tool_results == [\n            {\n                \"name\": \"Filesystem_list_files\",\n                \"output\": json.dumps([str(other_path)]),\n                \"tool_call_id\": None,\n            }\n        ]\n\n        # Should show an error if you attempt to llm -c with configured toolboxes\n        result5 = runner.invoke(\n            cli.cli,\n            [\"-c\", \"list them again\"],\n        )\n        assert result5.exit_code == 1\n        assert (\n            \"Error: Tool(s) Filesystem_list_files not found. Available tools:\"\n            in result5.output\n        )\n\n        # Test the logging worked\n        rows = list(logs_db.query(TOOL_RESULTS_SQL))\n        # JSON decode things in rows\n        for row in rows:\n            row[\"tool_calls\"] = json.loads(row[\"tool_calls\"])\n            row[\"tool_results\"] = json.loads(row[\"tool_results\"])\n        assert rows == [\n            {\n                \"model\": \"echo\",\n                \"tool_calls\": [\n                    {\n                        \"name\": \"Memory_set\",\n                        \"arguments\": '{\"key\": \"hi\", \"value\": \"two\"}',\n                    },\n                    {\"name\": \"Memory_get\", \"arguments\": '{\"key\": \"hi\"}'},\n                ],\n                \"tool_results\": [],\n            },\n            {\n                \"model\": \"echo\",\n                \"tool_calls\": [],\n                \"tool_results\": [\n                    {\n                        \"name\": \"Memory_set\",\n                        \"output\": \"null\",\n                        \"instance\": {\n                            \"name\": \"Memory\",\n                            \"plugin\": \"ToolboxPlugin\",\n                            \"arguments\": \"{}\",\n                        },\n                    },\n                    {\n                        \"name\": \"Memory_get\",\n                        \"output\": \"two\",\n                        \"instance\": {\n                            \"name\": \"Memory\",\n                            \"plugin\": \"ToolboxPlugin\",\n                            \"arguments\": \"{}\",\n                        },\n                    },\n                ],\n            },\n            {\n                \"model\": \"echo\",\n                \"tool_calls\": [{\"name\": \"Filesystem_list_files\", \"arguments\": \"{}\"}],\n                \"tool_results\": [],\n            },\n            {\n                \"model\": \"echo\",\n                \"tool_calls\": [],\n                \"tool_results\": [\n                    {\n                        \"name\": \"Filesystem_list_files\",\n                        \"output\": json.dumps([str(other_path)]),\n                        \"instance\": {\n                            \"name\": \"Filesystem\",\n                            \"plugin\": \"ToolboxPlugin\",\n                            \"arguments\": json.dumps({\"path\": str(my_dir2)}),\n                        },\n                    }\n                ],\n            },\n        ]\n\n    finally:\n        plugins.pm.unregister(name=\"ToolboxPlugin\")\n\n\ndef test_register_toolbox_fails_on_bad_class():\n    class BadTools:\n        def bad(self):\n            return \"this is bad\"\n\n    class BadToolsPlugin:\n        __name__ = \"BadToolsPlugin\"\n\n        @hookimpl\n        def register_tools(self, register):\n            # This should fail because BadTools is not a subclass of llm.Toolbox\n            register(BadTools)\n\n    try:\n        plugins.pm.register(BadToolsPlugin(), name=\"BadToolsPlugin\")\n        with pytest.raises(TypeError):\n            llm.get_tools()\n    finally:\n        plugins.pm.unregister(name=\"BadToolsPlugin\")\n\n\ndef test_toolbox_logging_async(logs_db, tmpdir):\n    path = pathlib.Path(tmpdir / \"path\")\n    path.mkdir()\n    runner = CliRunner()\n    try:\n        plugins.pm.register(ToolboxPlugin(), name=\"ToolboxPlugin\")\n\n        # Run Memory and Filesystem tests --async\n        result = runner.invoke(\n            cli.cli,\n            [\n                \"prompt\",\n                \"--async\",\n                \"-T\",\n                \"Memory\",\n                \"--tool\",\n                \"Filesystem({})\".format(json.dumps(str(path))),\n                json.dumps(\n                    {\n                        \"tool_calls\": [\n                            {\n                                \"name\": \"Memory_set\",\n                                \"arguments\": {\"key\": \"hi\", \"value\": \"two\"},\n                            },\n                            {\"name\": \"Memory_get\", \"arguments\": {\"key\": \"hi\"}},\n                            {\"name\": \"Filesystem_list_files\"},\n                        ]\n                    }\n                ),\n                \"-m\",\n                \"echo\",\n            ],\n        )\n        assert result.exit_code == 0\n        tool_results = json.loads(\n            \"[\" + result.output.split('\"tool_results\": [')[1].rsplit(\"]\", 1)[0] + \"]\"\n        )\n        assert tool_results == [\n            {\"name\": \"Memory_set\", \"output\": \"null\", \"tool_call_id\": None},\n            {\"name\": \"Memory_get\", \"output\": \"two\", \"tool_call_id\": None},\n            {\"name\": \"Filesystem_list_files\", \"output\": \"[]\", \"tool_call_id\": None},\n        ]\n    finally:\n        plugins.pm.unregister(name=\"ToolboxPlugin\")\n\n    # Check the database\n    rows = list(logs_db.query(TOOL_RESULTS_SQL))\n    # JSON decode things in rows\n    for row in rows:\n        row[\"tool_calls\"] = json.loads(row[\"tool_calls\"])\n        row[\"tool_results\"] = json.loads(row[\"tool_results\"])\n    assert rows == [\n        {\n            \"model\": \"echo\",\n            \"tool_calls\": [\n                {\"name\": \"Memory_set\", \"arguments\": '{\"key\": \"hi\", \"value\": \"two\"}'},\n                {\"name\": \"Memory_get\", \"arguments\": '{\"key\": \"hi\"}'},\n                {\"name\": \"Filesystem_list_files\", \"arguments\": \"{}\"},\n            ],\n            \"tool_results\": [],\n        },\n        {\n            \"model\": \"echo\",\n            \"tool_calls\": [],\n            \"tool_results\": [\n                {\n                    \"name\": \"Memory_set\",\n                    \"output\": \"null\",\n                    \"instance\": {\n                        \"name\": \"Filesystem\",\n                        \"plugin\": \"ToolboxPlugin\",\n                        \"arguments\": \"{}\",\n                    },\n                },\n                {\n                    \"name\": \"Memory_get\",\n                    \"output\": \"two\",\n                    \"instance\": {\n                        \"name\": \"Filesystem\",\n                        \"plugin\": \"ToolboxPlugin\",\n                        \"arguments\": \"{}\",\n                    },\n                },\n                {\n                    \"name\": \"Filesystem_list_files\",\n                    \"output\": \"[]\",\n                    \"instance\": {\n                        \"name\": \"Filesystem\",\n                        \"plugin\": \"ToolboxPlugin\",\n                        \"arguments\": json.dumps({\"path\": str(path)}),\n                    },\n                },\n            ],\n        },\n    ]\n\n\ndef test_plugins_command():\n    runner = CliRunner()\n    result = runner.invoke(cli.cli, [\"plugins\"])\n    assert result.exit_code == 0\n    expected = [\n        {\"name\": \"EchoModelPlugin\", \"hooks\": [\"register_models\"]},\n        {\n            \"name\": \"MockModelsPlugin\",\n            \"hooks\": [\"register_embedding_models\", \"register_models\"],\n        },\n    ]\n    actual = json.loads(result.output)\n    actual.sort(key=lambda p: p[\"name\"])\n    assert actual == expected\n    # Test the --hook option\n    result2 = runner.invoke(cli.cli, [\"plugins\", \"--hook\", \"register_embedding_models\"])\n    assert result2.exit_code == 0\n    assert json.loads(result2.output) == [\n        {\n            \"name\": \"MockModelsPlugin\",\n            \"hooks\": [\"register_embedding_models\", \"register_models\"],\n        },\n    ]\n\n\nTOOL_RESULTS_SQL = \"\"\"\n-- First, create ordered subqueries for tool_calls and tool_results\nwith ordered_tool_calls as (\n    select\n        tc.response_id,\n        json_group_array(\n            json_object(\n                'name', tc.name,\n                'arguments', tc.arguments\n            )\n        ) as tool_calls_json\n    from (\n        select * from tool_calls order by id\n    ) tc\n    where tc.id is not null\n    group by tc.response_id\n),\nordered_tool_results as (\n    select\n        tr.response_id,\n        json_group_array(\n            json_object(\n                'name', tr.name,\n                'output', tr.output,\n                'instance', case\n                    when ti.id is not null then json_object(\n                        'name', ti.name,\n                        'plugin', ti.plugin,\n                        'arguments', ti.arguments\n                    )\n                    else null\n                end\n            )\n        ) as tool_results_json\n    from (\n        select distinct tr.*, ti.id as ti_id, ti.name as ti_name,\n               ti.plugin, ti.arguments as ti_arguments\n        from tool_results tr\n        left join tool_instances ti on tr.instance_id = ti.id\n        order by tr.id\n    ) tr\n    left join tool_instances ti on tr.instance_id = ti.id\n    where tr.id is not null\n    group by tr.response_id\n)\nselect\n    r.model,\n    coalesce(otc.tool_calls_json, '[]') as tool_calls,\n    coalesce(otr.tool_results_json, '[]') as tool_results\nfrom responses r\nleft join ordered_tool_calls otc on r.id = otc.response_id\nleft join ordered_tool_results otr on r.id = otr.response_id\ngroup by r.id, r.model\norder by r.id\"\"\"\n"
  },
  {
    "path": "tests/test_templates.py",
    "content": "from click.testing import CliRunner\nfrom importlib.metadata import version\nimport json\nfrom llm import Template, Toolbox, hookimpl, user_dir\nfrom llm.cli import cli\nfrom llm.plugins import pm\nimport os\nfrom unittest import mock\nimport pathlib\nimport pytest\nimport textwrap\nimport yaml\n\n\n@pytest.mark.parametrize(\n    \"prompt,system,defaults,params,expected_prompt,expected_system,expected_error\",\n    (\n        (\"S: $input\", None, None, {}, \"S: input\", None, None),\n        (\"S: $input\", \"system\", None, {}, \"S: input\", \"system\", None),\n        (\"No vars\", None, None, {}, \"No vars\", None, None),\n        (\"$one and $two\", None, None, {}, None, None, \"Missing variables: one, two\"),\n        (\"$one and $two\", None, None, {\"one\": 1, \"two\": 2}, \"1 and 2\", None, None),\n        (\"$one and $two\", None, {\"one\": 1}, {\"two\": 2}, \"1 and 2\", None, None),\n        (\"$one and $$2\", None, None, {\"one\": 1}, \"1 and $2\", None, None),\n        (\n            \"$one and $two\",\n            None,\n            {\"one\": 99},\n            {\"one\": 1, \"two\": 2},\n            \"1 and 2\",\n            None,\n            None,\n        ),\n    ),\n)\ndef test_template_evaluate(\n    prompt, system, defaults, params, expected_prompt, expected_system, expected_error\n):\n    t = Template(name=\"t\", prompt=prompt, system=system, defaults=defaults)\n    if expected_error:\n        with pytest.raises(Template.MissingVariables) as ex:\n            prompt, system = t.evaluate(\"input\", params)\n        assert ex.value.args[0] == expected_error\n    else:\n        prompt, system = t.evaluate(\"input\", params)\n        assert prompt == expected_prompt\n        assert system == expected_system\n\n\ndef test_templates_list_no_templates_found():\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"templates\", \"list\"])\n    assert result.exit_code == 0\n    assert result.output == \"\"\n\n\n@pytest.mark.parametrize(\"args\", ([\"templates\", \"list\"], [\"templates\"]))\ndef test_templates_list(templates_path, args):\n    (templates_path / \"one.yaml\").write_text(\"template one\", \"utf-8\")\n    (templates_path / \"two.yaml\").write_text(\"template two\", \"utf-8\")\n    (templates_path / \"three.yaml\").write_text(\n        \"template three is very long \" * 4, \"utf-8\"\n    )\n    (templates_path / \"four.yaml\").write_text(\n        \"'this one\\n\\nhas newlines in it'\", \"utf-8\"\n    )\n    (templates_path / \"both.yaml\").write_text(\n        \"system: summarize this\\nprompt: $input\", \"utf-8\"\n    )\n    (templates_path / \"sys.yaml\").write_text(\"system: Summarize this\", \"utf-8\")\n    (templates_path / \"invalid.yaml\").write_text(\"system2: This is invalid\", \"utf-8\")\n    runner = CliRunner()\n    result = runner.invoke(cli, args)\n    assert result.exit_code == 0\n    assert result.output == (\n        \"both  : system: summarize this prompt: $input\\n\"\n        \"four  : this one has newlines in it\\n\"\n        \"one   : template one\\n\"\n        \"sys   : system: Summarize this\\n\"\n        \"three : template three is very long template three is very long template thre...\\n\"\n        \"two   : template two\\n\"\n    )\n\n\n@pytest.mark.parametrize(\n    \"args,expected,expected_error\",\n    (\n        ([\"-m\", \"gpt4\", \"hello\"], {\"model\": \"gpt-4\", \"prompt\": \"hello\"}, None),\n        ([\"hello $foo\"], {\"prompt\": \"hello $foo\"}, None),\n        ([\"--system\", \"system\"], {\"system\": \"system\"}, None),\n        ([\"-t\", \"template\"], None, \"--save cannot be used with --template\"),\n        ([\"--continue\"], None, \"--save cannot be used with --continue\"),\n        ([\"--cid\", \"123\"], None, \"--save cannot be used with --cid\"),\n        ([\"--conversation\", \"123\"], None, \"--save cannot be used with --cid\"),\n        (\n            [\"Say hello as $name\", \"-p\", \"name\", \"default-name\"],\n            {\"prompt\": \"Say hello as $name\", \"defaults\": {\"name\": \"default-name\"}},\n            None,\n        ),\n        # Options\n        (\n            [\"-o\", \"temperature\", \"0.5\", \"--system\", \"in french\"],\n            {\"system\": \"in french\", \"options\": {\"temperature\": 0.5}},\n            None,\n        ),\n        # -x/--extract should be persisted:\n        (\n            [\"--system\", \"write python\", \"--extract\"],\n            {\"system\": \"write python\", \"extract\": True},\n            None,\n        ),\n        # So should schemas (and should not sort properties)\n        (\n            [\n                \"--schema\",\n                '{\"properties\": {\"b\": {\"type\": \"string\"}, \"a\": {\"type\": \"string\"}}}',\n            ],\n            {\n                \"schema_object\": {\n                    \"properties\": {\"b\": {\"type\": \"string\"}, \"a\": {\"type\": \"string\"}}\n                }\n            },\n            None,\n        ),\n        # And fragments and system_fragments\n        (\n            [\"--fragment\", \"f1.txt\", \"--system-fragment\", \"https://example.com/f2.txt\"],\n            {\n                \"fragments\": [\"f1.txt\"],\n                \"system_fragments\": [\"https://example.com/f2.txt\"],\n            },\n            None,\n        ),\n        # And attachments and attachment_types\n        (\n            [\"--attachment\", \"a.txt\", \"--attachment-type\", \"b.txt\", \"text/plain\"],\n            {\n                \"attachments\": [\"a.txt\"],\n                \"attachment_types\": [{\"type\": \"text/plain\", \"value\": \"b.txt\"}],\n            },\n            None,\n        ),\n        # Model option using an enum: https://github.com/simonw/llm/issues/1237\n        (\n            [\"-m\", \"gpt-5\", \"-o\", \"reasoning_effort\", \"minimal\"],\n            {\n                \"model\": \"gpt-5\",\n                \"options\": {\"reasoning_effort\": \"minimal\"},\n            },\n            None,\n        ),\n    ),\n)\ndef test_templates_prompt_save(templates_path, args, expected, expected_error):\n    assert not (templates_path / \"saved.yaml\").exists()\n    runner = CliRunner()\n    with runner.isolated_filesystem():\n        # Create a file to test attachment\n        pathlib.Path(\"a.txt\").write_text(\"attachment\", \"utf-8\")\n        pathlib.Path(\"b.txt\").write_text(\"attachment type\", \"utf-8\")\n        result = runner.invoke(cli, args + [\"--save\", \"saved\"], catch_exceptions=False)\n    if not expected_error:\n        assert result.exit_code == 0\n        yaml_data = yaml.safe_load((templates_path / \"saved.yaml\").read_text(\"utf-8\"))\n        # Adjust attachment and attachment_types paths to be just the filename\n        if \"attachments\" in yaml_data:\n            yaml_data[\"attachments\"] = [\n                os.path.basename(path) for path in yaml_data[\"attachments\"]\n            ]\n        for item in yaml_data.get(\"attachment_types\", []):\n            item[\"value\"] = os.path.basename(item[\"value\"])\n        assert yaml_data == expected\n    else:\n        assert result.exit_code == 1\n        assert expected_error in result.output\n\n\ndef test_templates_error_on_missing_schema(templates_path):\n    runner = CliRunner()\n    runner.invoke(\n        cli, [\"the-prompt\", \"--save\", \"prompt_no_schema\"], catch_exceptions=False\n    )\n    # This should complain about no schema\n    result = runner.invoke(\n        cli, [\"hi\", \"--schema\", \"t:prompt_no_schema\"], catch_exceptions=False\n    )\n    assert result.output == \"Error: Template 'prompt_no_schema' has no schema\\n\"\n    # And this is just an invalid template\n    result2 = runner.invoke(\n        cli, [\"hi\", \"--schema\", \"t:bad_template\"], catch_exceptions=False\n    )\n    assert result2.output == \"Error: Invalid template: bad_template\\n\"\n\n\n@mock.patch.dict(os.environ, {\"OPENAI_API_KEY\": \"X\"})\n@pytest.mark.parametrize(\n    \"template,input_text,extra_args,expected_model,expected_input,expected_error,expected_options\",\n    (\n        (\n            \"'Summarize this: $input'\",\n            \"Input text\",\n            [],\n            \"gpt-4o-mini\",\n            \"Summarize this: Input text\",\n            None,\n            None,\n        ),\n        (\n            \"prompt: 'Summarize this: $input'\\nmodel: gpt-4\",\n            \"Input text\",\n            [],\n            \"gpt-4\",\n            \"Summarize this: Input text\",\n            None,\n            None,\n        ),\n        (\n            \"prompt: 'Summarize this: $input'\",\n            \"Input text\",\n            [\"-m\", \"4\"],\n            \"gpt-4\",\n            \"Summarize this: Input text\",\n            None,\n            None,\n        ),\n        # -s system prompt should over-ride template system prompt\n        pytest.param(\n            \"boo\",\n            \"Input text\",\n            [\"-s\", \"custom system\"],\n            \"gpt-4o-mini\",\n            [\n                {\"role\": \"system\", \"content\": \"custom system\"},\n                {\"role\": \"user\", \"content\": \"boo\\nInput text\"},\n            ],\n            None,\n            None,\n            marks=pytest.mark.httpx_mock(),\n        ),\n        pytest.param(\n            \"prompt: 'Say $hello'\",\n            \"Input text\",\n            [],\n            None,\n            None,\n            \"Error: Missing variables: hello\",\n            None,\n            marks=pytest.mark.httpx_mock(),\n        ),\n        # Template generated prompt should combine with CLI prompt\n        (\n            \"prompt: 'Say $hello'\",\n            \"Input text\",\n            [\"-p\", \"hello\", \"Blah\"],\n            \"gpt-4o-mini\",\n            \"Say Blah\\nInput text\",\n            None,\n            None,\n        ),\n        (\n            \"prompt: 'Say pelican'\",\n            \"\",\n            [],\n            \"gpt-4o-mini\",\n            \"Say pelican\",\n            None,\n            None,\n        ),\n        # Template with just a system prompt\n        (\n            \"system: 'Summarize this'\",\n            \"Input text\",\n            [],\n            \"gpt-4o-mini\",\n            [\n                {\"content\": \"Summarize this\", \"role\": \"system\"},\n                {\"content\": \"Input text\", \"role\": \"user\"},\n            ],\n            None,\n            None,\n        ),\n        # Options\n        (\n            \"prompt: 'Summarize this: $input'\\noptions:\\n  temperature: 0.5\",\n            \"Input text\",\n            [],\n            \"gpt-4o-mini\",\n            \"Summarize this: Input text\",\n            None,\n            {\"temperature\": 0.5},\n        ),\n        # Should be over-ridden by CLI\n        (\n            \"prompt: 'Summarize this: $input'\\noptions:\\n  temperature: 0.5\",\n            \"Input text\",\n            [\"-o\", \"temperature\", \"0.7\"],\n            \"gpt-4o-mini\",\n            \"Summarize this: Input text\",\n            None,\n            {\"temperature\": 0.7},\n        ),\n    ),\n)\ndef test_execute_prompt_with_a_template(\n    templates_path,\n    mocked_openai_chat,\n    template,\n    input_text,\n    extra_args,\n    expected_model,\n    expected_input,\n    expected_error,\n    expected_options,\n):\n    (templates_path / \"template.yaml\").write_text(template, \"utf-8\")\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\"--no-stream\", \"-t\", \"template\"]\n        + ([input_text] if input_text else [])\n        + extra_args,\n        catch_exceptions=False,\n    )\n    if isinstance(expected_input, str):\n        expected_messages = [{\"role\": \"user\", \"content\": expected_input}]\n    else:\n        expected_messages = expected_input\n\n    if expected_error is None:\n        assert result.exit_code == 0\n        last_request = mocked_openai_chat.get_requests()[-1]\n        expected_data = {\n            \"model\": expected_model,\n            \"messages\": expected_messages,\n            \"stream\": False,\n        }\n        if expected_options:\n            expected_data.update(expected_options)\n        assert json.loads(last_request.content) == expected_data\n    else:\n        assert result.exit_code == 1\n        assert result.output.strip() == expected_error\n        mocked_openai_chat.reset()\n\n\n@pytest.mark.parametrize(\n    \"template,expected\",\n    (\n        (\n            \"system: system\\nprompt: prompt\",\n            {\n                \"prompt\": \"prompt\",\n                \"system\": \"system\",\n                \"attachments\": [],\n                \"stream\": True,\n                \"previous\": [],\n            },\n        ),\n        (\n            \"prompt: |\\n  This is\\n  ```\\n  code to extract\\n  ```\",\n            {\n                \"prompt\": \"This is\\n```\\ncode to extract\\n```\",\n                \"system\": \"\",\n                \"attachments\": [],\n                \"stream\": True,\n                \"previous\": [],\n            },\n        ),\n        # Now try that with extract: true\n        (\n            'extract: true\\nprompt: |\\n  {\"raw\": \"This is\\\\n```\\\\ncode to extract\\\\n```\"}',\n            \"code to extract\",\n        ),\n    ),\n)\ndef test_execute_prompt_from_template_url(httpx_mock, template, expected):\n    httpx_mock.add_response(\n        url=\"https://example.com/prompt.yaml\",\n        method=\"GET\",\n        text=template,\n        status_code=200,\n    )\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\"-t\", \"https://example.com/prompt.yaml\", \"-m\", \"echo\"],\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    if isinstance(expected, dict):\n        assert json.loads(result.output.strip()) == expected\n    else:\n        assert result.output.strip() == expected\n\n\ndef test_execute_prompt_from_template_path():\n    runner = CliRunner()\n    with runner.isolated_filesystem() as temp_dir:\n        path = pathlib.Path(temp_dir) / \"my-template.yaml\"\n        path.write_text(\"system: system\\nprompt: prompt\", \"utf-8\")\n        result = runner.invoke(\n            cli,\n            [\"-t\", str(path), \"-m\", \"echo\"],\n            catch_exceptions=False,\n        )\n        assert result.exit_code == 0, result.output\n        assert json.loads(result.output) == {\n            \"prompt\": \"prompt\",\n            \"system\": \"system\",\n            \"attachments\": [],\n            \"stream\": True,\n            \"previous\": [],\n        }\n\n\ndef test_template_respects_cli_extract_flag(\n    mocked_openai_chat_returning_fenced_code, templates_path\n):\n    (templates_path / \"code.yaml\").write_text(\"prompt: Write code\", \"utf-8\")\n    runner = CliRunner()\n    result = runner.invoke(\n        cli,\n        [\"-t\", \"code\", \"-m\", \"gpt-4o-mini\", \"--key\", \"x\", \"-x\"],\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    assert \"```\" not in result.output\n    assert result.output.strip() == \"function foo() {\\n  return 'bar';\\n}\"\n\n\nFUNCTIONS_EXAMPLE = \"\"\"\ndef greet(name: str) -> str:\n    return f\"Hello, {name}!\"\n\"\"\"\n\n\nclass Greeting(Toolbox):\n    def __init__(self, greeting: str):\n        self.greeting = greeting\n\n    def greet(self, name: str) -> str:\n        \"Greet name with a greeting\"\n        return f\"{self.greeting}, {name}!\"\n\n\nclass GreetingsPlugin:\n    __name__ = \"GreetingsPlugin\"\n\n    @hookimpl\n    def register_tools(self, register):\n        register(Greeting)\n\n\n@pytest.mark.parametrize(\n    \"source,expected_tool_success,expected_functions_success\",\n    (\n        (\"alias\", True, True),\n        (\"file\", True, True),\n        # Loaded from URL or plugin = functions: should not work\n        (\"url\", True, False),\n        (\"plugin\", True, False),\n    ),\n)\ndef test_tools_in_templates(\n    source, expected_tool_success, expected_functions_success, httpx_mock, tmpdir\n):\n    template_yaml = textwrap.dedent(\"\"\"\n    name: test\n    tools:\n    - llm_version\n    - Greeting(\"hi\")\n    functions: |\n      def demo():\n          return \"Demo\"\n    \"\"\")\n    args = []\n\n    def before():\n        pass\n\n    def after():\n        pass\n\n    if source == \"alias\":\n        args = [\"-t\", \"test\"]\n        (user_dir() / \"templates\").mkdir(parents=True, exist_ok=True)\n        (user_dir() / \"templates\" / \"test.yaml\").write_text(template_yaml, \"utf-8\")\n    elif source == \"file\":\n        (tmpdir / \"test.yaml\").write_text(template_yaml, \"utf-8\")\n        args = [\"-t\", str(tmpdir / \"test.yaml\")]\n    elif source == \"url\":\n        httpx_mock.add_response(\n            url=\"https://example.com/test.yaml\",\n            method=\"GET\",\n            text=template_yaml,\n            status_code=200,\n            is_reusable=True,\n        )\n        args = [\"-t\", \"https://example.com/test.yaml\"]\n    elif source == \"plugin\":\n\n        class LoadTemplatePlugin:\n            __name__ = \"LoadTemplatePlugin\"\n\n            @hookimpl\n            def register_template_loaders(self, register):\n                register(\n                    \"tool-template\",\n                    lambda s: Template(\n                        name=\"tool-template\",\n                        tools=[\"llm_version\", 'Greeting(\"hi\")'],\n                        functions=FUNCTIONS_EXAMPLE,\n                    ),\n                )\n\n        def before():\n            pm.register(LoadTemplatePlugin(), name=\"test-tools-in-templates\")\n\n        def after():\n            pm.unregister(name=\"test-tools-in-templates\")\n\n        args = [\"-t\", \"tool-template:\"]\n\n    before()\n    pm.register(GreetingsPlugin(), name=\"greetings-plugin\")\n    try:\n        runner = CliRunner()\n        # Test llm_version, then Greeting, then demo\n        for tool_call, text, should_be_present in (\n            ({\"name\": \"llm_version\"}, version(\"llm\"), True),\n            (\n                {\"name\": \"Greeting_greet\", \"arguments\": {\"name\": \"Alice\"}},\n                \"hi, Alice\",\n                expected_tool_success,\n            ),\n            (\n                {\"name\": \"Greeting_greet\", \"arguments\": {\"name\": \"Bob\"}},\n                \"hi, Bob!\",\n                expected_tool_success,\n            ),\n            ({\"name\": \"demo\"}, '\"output\": \"Demo\"', expected_functions_success),\n        ):\n            result = runner.invoke(\n                cli,\n                args\n                + [\n                    \"-m\",\n                    \"echo\",\n                    \"--no-stream\",\n                    json.dumps({\"tool_calls\": [tool_call]}),\n                ],\n                catch_exceptions=False,\n            )\n            assert result.exit_code == 0\n            if should_be_present:\n                assert text in result.output\n            else:\n                assert text not in result.output\n    finally:\n        after()\n        pm.unregister(name=\"greetings-plugin\")\n"
  },
  {
    "path": "tests/test_tools.py",
    "content": "import asyncio\nfrom click.testing import CliRunner\nfrom importlib.metadata import version\nimport json\nimport llm\nfrom llm import cli, CancelToolCall\nfrom llm.migrations import migrate\nfrom llm.tools import llm_time\nimport os\nimport pytest\nimport sqlite_utils\nimport time\n\nAPI_KEY = os.environ.get(\"PYTEST_OPENAI_API_KEY\", None) or \"badkey\"\n\n\n@pytest.mark.vcr\ndef test_tool_use_basic(vcr):\n    model = llm.get_model(\"gpt-4o-mini\")\n\n    def multiply(a: int, b: int) -> int:\n        \"\"\"Multiply two numbers.\"\"\"\n        return a * b\n\n    chain_response = model.chain(\"What is 1231 * 2331?\", tools=[multiply], key=API_KEY)\n\n    output = \"\".join(chain_response)\n\n    assert output == \"The result of \\\\( 1231 \\\\times 2331 \\\\) is \\\\( 2,869,461 \\\\).\"\n\n    first, second = chain_response._responses\n\n    assert first.prompt.prompt == \"What is 1231 * 2331?\"\n    assert first.prompt.tools[0].name == \"multiply\"\n\n    assert len(second.prompt.tool_results) == 1\n    assert second.prompt.tool_results[0].name == \"multiply\"\n    assert second.prompt.tool_results[0].output == \"2869461\"\n\n    # Test writing to the database\n    db = sqlite_utils.Database(memory=True)\n    migrate(db)\n    chain_response.log_to_db(db)\n    assert set(db.table_names()).issuperset(\n        {\"tools\", \"tool_responses\", \"tool_calls\", \"tool_results\"}\n    )\n\n    responses = list(db[\"responses\"].rows)\n    assert len(responses) == 2\n    first_response, second_response = responses\n\n    tools = list(db[\"tools\"].rows)\n    assert len(tools) == 1\n    assert tools[0][\"name\"] == \"multiply\"\n    assert tools[0][\"description\"] == \"Multiply two numbers.\"\n    assert tools[0][\"plugin\"] is None\n\n    tool_results = list(db[\"tool_results\"].rows)\n    tool_calls = list(db[\"tool_calls\"].rows)\n\n    assert len(tool_calls) == 1\n    assert tool_calls[0][\"response_id\"] == first_response[\"id\"]\n    assert tool_calls[0][\"name\"] == \"multiply\"\n    assert tool_calls[0][\"arguments\"] == '{\"a\": 1231, \"b\": 2331}'\n\n    assert len(tool_results) == 1\n    assert tool_results[0][\"response_id\"] == second_response[\"id\"]\n    assert tool_results[0][\"output\"] == \"2869461\"\n    assert tool_results[0][\"tool_call_id\"] == tool_calls[0][\"tool_call_id\"]\n\n\n@pytest.mark.vcr\ndef test_tool_use_chain_of_two_calls(vcr):\n    model = llm.get_model(\"gpt-4o-mini\")\n\n    def lookup_population(country: str) -> int:\n        \"Returns the current population of the specified fictional country\"\n        return 123124\n\n    def can_have_dragons(population: int) -> bool:\n        \"Returns True if the specified population can have dragons, False otherwise\"\n        return population > 10000\n\n    chain_response = model.chain(\n        \"Can the country of Crumpet have dragons? Answer with only YES or NO\",\n        tools=[lookup_population, can_have_dragons],\n        stream=False,\n        key=API_KEY,\n    )\n\n    output = chain_response.text()\n    assert output == \"YES\"\n    assert len(chain_response._responses) == 3\n\n    first, second, third = chain_response._responses\n    assert first.tool_calls()[0].arguments == {\"country\": \"Crumpet\"}\n    assert first.prompt.tool_results == []\n    assert second.prompt.tool_results[0].output == \"123124\"\n    assert second.tool_calls()[0].arguments == {\"population\": 123124}\n    assert third.prompt.tool_results[0].output == \"true\"\n    assert third.tool_calls() == []\n\n\ndef test_tool_use_async_tool_function():\n    async def hello():\n        return \"world\"\n\n    model = llm.get_model(\"echo\")\n    chain_response = model.chain(\n        json.dumps({\"tool_calls\": [{\"name\": \"hello\"}]}), tools=[hello]\n    )\n    output = chain_response.text()\n    # That's two JSON objects separated by '\\n}{\\n'\n    bits = output.split(\"\\n}{\\n\")\n    assert len(bits) == 2\n    objects = [json.loads(bits[0] + \"}\"), json.loads(\"{\" + bits[1])]\n    assert objects == [\n        {\"prompt\": \"\", \"system\": \"\", \"attachments\": [], \"stream\": True, \"previous\": []},\n        {\n            \"prompt\": \"\",\n            \"system\": \"\",\n            \"attachments\": [],\n            \"stream\": True,\n            \"previous\": [{\"prompt\": '{\"tool_calls\": [{\"name\": \"hello\"}]}'}],\n            \"tool_results\": [\n                {\"name\": \"hello\", \"output\": \"world\", \"tool_call_id\": None}\n            ],\n        },\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_async_tools_run_tools_in_parallel():\n    start_timestamps = []\n\n    start_ns = time.monotonic_ns()\n\n    async def hello():\n        start_timestamps.append((\"hello\", time.monotonic_ns() - start_ns))\n        await asyncio.sleep(0.2)\n        return \"world\"\n\n    async def hello2():\n        start_timestamps.append((\"hello2\", time.monotonic_ns() - start_ns))\n        await asyncio.sleep(0.2)\n        return \"world2\"\n\n    model = llm.get_async_model(\"echo\")\n    chain_response = model.chain(\n        json.dumps({\"tool_calls\": [{\"name\": \"hello\"}, {\"name\": \"hello2\"}]}),\n        tools=[hello, hello2],\n    )\n    output = await chain_response.text()\n    # That's two JSON objects separated by '\\n}{\\n'\n    bits = output.split(\"\\n}{\\n\")\n    assert len(bits) == 2\n    objects = [json.loads(bits[0] + \"}\"), json.loads(\"{\" + bits[1])]\n    assert objects == [\n        {\"prompt\": \"\", \"system\": \"\", \"attachments\": [], \"stream\": True, \"previous\": []},\n        {\n            \"prompt\": \"\",\n            \"system\": \"\",\n            \"attachments\": [],\n            \"stream\": True,\n            \"previous\": [\n                {\"prompt\": '{\"tool_calls\": [{\"name\": \"hello\"}, {\"name\": \"hello2\"}]}'}\n            ],\n            \"tool_results\": [\n                {\"name\": \"hello\", \"output\": \"world\", \"tool_call_id\": None},\n                {\"name\": \"hello2\", \"output\": \"world2\", \"tool_call_id\": None},\n            ],\n        },\n    ]\n    delta_ns = start_timestamps[1][1] - start_timestamps[0][1]\n    # They should have run in parallel so it should be less than 0.02s difference\n    assert delta_ns < (100_000_000 * 0.2)\n\n\n@pytest.mark.asyncio\nasync def test_async_toolbox():\n    class Tools(llm.Toolbox):\n        def __init__(self):\n            self.prepared = False\n\n        async def go(self):\n            await asyncio.sleep(0)\n            return \"This was async\"\n\n        async def prepare_async(self):\n            await asyncio.sleep(0)\n            self.prepared = True\n\n    instance = Tools()\n    assert instance.prepared is False\n\n    model = llm.get_async_model(\"echo\")\n    chain_response = model.chain(\n        json.dumps({\"tool_calls\": [{\"name\": \"Tools_go\"}]}),\n        tools=[instance],\n    )\n    output = await chain_response.text()\n    assert '\"output\": \"This was async\"' in output\n    assert instance.prepared is True\n\n\ndef test_toolbox_add_tool():\n    model = llm.get_model(\"echo\")\n\n    class Tools(llm.Toolbox):\n        def __init__(self):\n            self.prepared = False\n\n        def original(self):\n            return \"Original method\"\n\n        def prepare(self):\n            self.prepared = True\n\n    def new_method():\n        return \"New method\"\n\n    tools = Tools()\n    tools.add_tool(new_method)\n    assert not tools.prepared\n\n    chain_response = model.chain(\n        json.dumps({\"tool_calls\": [{\"name\": \"new_method\"}]}),\n        tools=[tools],\n    )\n    output = chain_response.text()\n    assert '\"output\": \"New method\"' in output\n    assert tools.prepared\n\n\ndef test_toolbox_add_tool_with_pass_self():\n    model = llm.get_model(\"echo\")\n\n    class Tools(llm.Toolbox):\n        def __init__(self, hotdog):\n            self.hotdog = hotdog\n\n        def original(self):\n            return \"Original method\"\n\n    def new_method(self):\n        return self.hotdog\n\n    tools = Tools(\"doghot\")\n    tools.add_tool(new_method, pass_self=True)\n\n    chain_response = model.chain(\n        json.dumps({\"tool_calls\": [{\"name\": \"new_method\"}]}),\n        tools=[tools],\n    )\n    output = chain_response.text()\n    assert '\"output\": \"doghot\"' in output\n\n\n@pytest.mark.vcr\ndef test_conversation_with_tools(vcr):\n    import llm\n\n    def add(a: int, b: int) -> int:\n        return a + b\n\n    def multiply(a: int, b: int) -> int:\n        return a * b\n\n    model = llm.get_model(\"echo\")\n    conversation = model.conversation(tools=[add, multiply])\n\n    output1 = conversation.chain(\n        json.dumps(\n            {\"tool_calls\": [{\"name\": \"multiply\", \"arguments\": {\"a\": 5324, \"b\": 23233}}]}\n        )\n    ).text()\n    assert \"123692492\" in output1\n    output2 = conversation.chain(\n        json.dumps(\n            {\n                \"tool_calls\": [\n                    {\"name\": \"add\", \"arguments\": {\"a\": 841758375, \"b\": 123123}}\n                ]\n            }\n        )\n    ).text()\n    assert \"841881498\" in output2\n\n\ndef test_default_tool_llm_version():\n    runner = CliRunner()\n    result = runner.invoke(\n        cli.cli,\n        [\n            \"-m\",\n            \"echo\",\n            \"-T\",\n            \"llm_version\",\n            json.dumps({\"tool_calls\": [{\"name\": \"llm_version\"}]}),\n        ],\n    )\n    assert result.exit_code == 0\n    assert '\"output\": \"{}\"'.format(version(\"llm\")) in result.output\n\n\ndef test_cli_tools_with_options():\n    runner = CliRunner()\n    result = runner.invoke(\n        cli.cli,\n        [\n            \"-m\",\n            \"mock\",\n            \"-o\",\n            \"max_tokens\",\n            \"10\",\n            \"-T\",\n            \"llm_version\",\n            json.dumps({\"tool_calls\": [{\"name\": \"llm_version\"}]}),\n        ],\n        catch_exceptions=False,\n    )\n    assert result.exit_code == 0\n    # It just needs not to crash\n    # https://github.com/simonw/llm/issues/1233\n\n\ndef test_functions_tool_locals():\n    # https://github.com/simonw/llm/issues/1107\n    runner = CliRunner()\n    result = runner.invoke(\n        cli.cli,\n        [\n            \"-m\",\n            \"echo\",\n            \"--functions\",\n            \"my_locals = locals\",\n            \"-T\",\n            \"llm_version\",\n            json.dumps({\"tool_calls\": [{\"name\": \"locals\"}]}),\n        ],\n    )\n    assert result.exit_code == 0\n\n\ndef test_default_tool_llm_time():\n    runner = CliRunner()\n    result = runner.invoke(\n        cli.cli,\n        [\n            \"-m\",\n            \"echo\",\n            \"-T\",\n            \"llm_time\",\n            json.dumps({\"tool_calls\": [{\"name\": \"llm_time\"}]}),\n        ],\n    )\n    assert result.exit_code == 0\n    assert \"timezone_offset\" in result.output\n\n    # Test it by calling it directly\n    info = llm_time()\n    assert set(info.keys()) == {\n        \"timezone_offset\",\n        \"utc_time_iso\",\n        \"local_time\",\n        \"local_timezone\",\n        \"utc_time\",\n        \"is_dst\",\n    }\n\n\ndef test_incorrect_tool_usage():\n    model = llm.get_model(\"echo\")\n\n    def simple(name: str):\n        return name\n\n    chain_response = model.chain(\n        json.dumps({\"tool_calls\": [{\"name\": \"bad_tool\"}]}),\n        tools=[simple],\n    )\n    output = chain_response.text()\n    assert 'Error: tool \\\\\"bad_tool\\\\\" does not exist' in output\n\n\ndef test_tool_returning_attachment():\n    model = llm.get_model(\"echo\")\n\n    def return_attachment() -> llm.Attachment:\n        return llm.ToolOutput(\n            \"Output\",\n            attachments=[\n                llm.Attachment(\n                    content=b\"This is a test attachment\",\n                    type=\"image/png\",\n                )\n            ],\n        )\n\n    chain_response = model.chain(\n        json.dumps({\"tool_calls\": [{\"name\": \"return_attachment\"}]}),\n        tools=[return_attachment],\n    )\n    output = chain_response.text()\n    assert '\"type\": \"image/png\"' in output\n    assert '\"output\": \"Output\"' in output\n\n\n@pytest.mark.asyncio\nasync def test_async_tool_returning_attachment():\n    model = llm.get_async_model(\"echo\")\n\n    async def return_attachment() -> llm.Attachment:\n        return llm.ToolOutput(\n            \"Output\",\n            attachments=[\n                llm.Attachment(\n                    content=b\"This is a test attachment\",\n                    type=\"image/png\",\n                )\n            ],\n        )\n\n    chain_response = model.chain(\n        json.dumps({\"tool_calls\": [{\"name\": \"return_attachment\"}]}),\n        tools=[return_attachment],\n    )\n    output = await chain_response.text()\n    assert '\"type\": \"image/png\"' in output\n    assert '\"output\": \"Output\"' in output\n\n\ndef test_tool_conversation_settings():\n    model = llm.get_model(\"echo\")\n    before_collected = []\n    after_collected = []\n\n    def before(*args):\n        before_collected.append(args)\n\n    def after(*args):\n        after_collected.append(args)\n\n    conversation = model.conversation(\n        tools=[llm_time], before_call=before, after_call=after\n    )\n    # Run two things\n    conversation.chain(json.dumps({\"tool_calls\": [{\"name\": \"llm_time\"}]})).text()\n    conversation.chain(json.dumps({\"tool_calls\": [{\"name\": \"llm_time\"}]})).text()\n    assert len(before_collected) == 2\n    assert len(after_collected) == 2\n\n\n@pytest.mark.asyncio\nasync def test_tool_conversation_settings_async():\n    model = llm.get_async_model(\"echo\")\n    before_collected = []\n    after_collected = []\n\n    async def before(*args):\n        before_collected.append(args)\n\n    async def after(*args):\n        after_collected.append(args)\n\n    conversation = model.conversation(\n        tools=[llm_time], before_call=before, after_call=after\n    )\n    await conversation.chain(json.dumps({\"tool_calls\": [{\"name\": \"llm_time\"}]})).text()\n    await conversation.chain(json.dumps({\"tool_calls\": [{\"name\": \"llm_time\"}]})).text()\n    assert len(before_collected) == 2\n    assert len(after_collected) == 2\n\n\nERROR_FUNCTION = \"\"\"\ndef trigger_error(msg: str):\n    raise Exception(msg)\n\"\"\"\n\n\n@pytest.mark.parametrize(\"async_\", (False, True))\ndef test_tool_errors(async_):\n    # https://github.com/simonw/llm/issues/1107\n    runner = CliRunner()\n    result = runner.invoke(\n        cli.cli,\n        (\n            [\n                \"-m\",\n                \"echo\",\n                \"--functions\",\n                ERROR_FUNCTION,\n                json.dumps(\n                    {\n                        \"tool_calls\": [\n                            {\"name\": \"trigger_error\", \"arguments\": {\"msg\": \"Error!\"}}\n                        ]\n                    }\n                ),\n            ]\n            + ([\"--async\"] if async_ else [])\n        ),\n    )\n    assert result.exit_code == 0\n    assert '\"output\": \"Error: Error!\"' in result.output\n    # llm logs --json output\n    log_json_result = runner.invoke(cli.cli, [\"logs\", \"--json\", \"-c\"])\n    assert log_json_result.exit_code == 0\n    log_data = json.loads(log_json_result.output)\n    assert len(log_data) == 2\n    assert log_data[1][\"tool_results\"][0][\"exception\"] == \"Exception: Error!\"\n    # llm logs -c output\n    log_text_result = runner.invoke(cli.cli, [\"logs\", \"-c\"])\n    assert log_text_result.exit_code == 0\n    assert (\n        \"- **trigger_error**: `None`<br>\\n\"\n        \"    Error: Error!<br>\\n\"\n        \"    **Error**: Exception: Error!\\n\"\n    ) in log_text_result.output\n\n\ndef test_chain_sync_cancel_only_first_of_two():\n    model = llm.get_model(\"echo\")\n\n    def t1() -> str:\n        return \"ran1\"\n\n    def t2() -> str:\n        return \"ran2\"\n\n    def before(tool, tool_call):\n        if tool.name == \"t1\":\n            raise CancelToolCall(\"skip1\")\n        # allow t2\n        return None\n\n    calls = [\n        {\"name\": \"t1\"},\n        {\"name\": \"t2\"},\n    ]\n    payload = json.dumps({\"tool_calls\": calls})\n    chain = model.chain(payload, tools=[t1, t2], before_call=before)\n    _ = chain.text()\n\n    # second response has two results\n    second = chain._responses[1]\n    results = second.prompt.tool_results\n    assert len(results) == 2\n\n    # first cancelled, second executed\n    assert results[0].name == \"t1\"\n    assert results[0].output == \"Cancelled: skip1\"\n    assert isinstance(results[0].exception, CancelToolCall)\n\n    assert results[1].name == \"t2\"\n    assert results[1].output == \"ran2\"\n    assert results[1].exception is None\n\n\n# 2c async equivalent\n@pytest.mark.asyncio\nasync def test_chain_async_cancel_only_first_of_two():\n    async_model = llm.get_async_model(\"echo\")\n\n    def t1() -> str:\n        return \"ran1\"\n\n    async def t2() -> str:\n        return \"ran2\"\n\n    async def before(tool, tool_call):\n        if tool.name == \"t1\":\n            raise CancelToolCall(\"skip1\")\n        return None\n\n    calls = [\n        {\"name\": \"t1\"},\n        {\"name\": \"t2\"},\n    ]\n    payload = json.dumps({\"tool_calls\": calls})\n    chain = async_model.chain(payload, tools=[t1, t2], before_call=before)\n    _ = await chain.text()\n\n    second = chain._responses[1]\n    results = second.prompt.tool_results\n    assert len(results) == 2\n\n    assert results[0].name == \"t1\"\n    assert results[0].output == \"Cancelled: skip1\"\n    assert isinstance(results[0].exception, CancelToolCall)\n\n    assert results[1].name == \"t2\"\n    assert results[1].output == \"ran2\"\n    assert results[1].exception is None\n"
  },
  {
    "path": "tests/test_tools_streaming.py",
    "content": "import llm\nfrom llm.tools import llm_version\nimport os\nimport pytest\n\nAPI_KEY = os.environ.get(\"PYTEST_OPENAI_API_KEY\", None) or \"badkey\"\n\n\n# This response contains streaming variant \"a\" where arguments=\"\" is followed by arguments=\"{}\"\n@pytest.mark.vcr(record_mode=\"none\")\ndef test_tools_streaming_variant_a():\n    model = llm.get_model(\"gpt-4.1-mini\")\n    chain = model.chain(\n        \"What is the current llm version?\", tools=[llm_version], key=API_KEY\n    )\n    assert \"\".join(chain) == \"The current version of *llm* is **0.fixed-version**.\"\n\n\n# This response contains streaming variant \"b\" where arguments=\"{}\" is the first partial stream received.\n@pytest.mark.vcr(record_mode=\"none\")\ndef test_tools_streaming_variant_b():\n    model = llm.get_model(\"gpt-4.1-mini\")\n    chain = model.chain(\n        \"What is the current llm version?\", tools=[llm_version], key=API_KEY\n    )\n    assert \"\".join(chain) == \"The current version of *llm* is **0.fixed-version**.\"\n\n\n# This response contains streaming variant \"c\".\n@pytest.mark.vcr(record_mode=\"none\")\ndef test_tools_streaming_variant_c():\n    model = llm.get_model(\"gpt-4.1-mini\")\n    chain = model.chain(\n        \"What is the current llm version?\", tools=[llm_version], key=API_KEY\n    )\n    assert (\n        \"\".join(chain)\n        == \"The installed version of LLM on this system is 0.fixed-version.\"\n    )\n"
  },
  {
    "path": "tests/test_utils.py",
    "content": "import json\nimport pytest\nfrom llm.utils import (\n    extract_fenced_code_block,\n    instantiate_from_spec,\n    maybe_fenced_code,\n    schema_dsl,\n    simplify_usage_dict,\n    truncate_string,\n    monotonic_ulid,\n)\nfrom llm import get_key, Toolbox\n\n\n@pytest.mark.parametrize(\n    \"input_data,expected_output\",\n    [\n        (\n            {\n                \"prompt_tokens_details\": {\"cached_tokens\": 0, \"audio_tokens\": 0},\n                \"completion_tokens_details\": {\n                    \"reasoning_tokens\": 0,\n                    \"audio_tokens\": 1,\n                    \"accepted_prediction_tokens\": 0,\n                    \"rejected_prediction_tokens\": 0,\n                },\n            },\n            {\"completion_tokens_details\": {\"audio_tokens\": 1}},\n        ),\n        (\n            {\n                \"details\": {\"tokens\": 5, \"audio_tokens\": 2},\n                \"more_details\": {\"accepted_tokens\": 3},\n            },\n            {\n                \"details\": {\"tokens\": 5, \"audio_tokens\": 2},\n                \"more_details\": {\"accepted_tokens\": 3},\n            },\n        ),\n        ({\"details\": {\"tokens\": 0, \"audio_tokens\": 0}, \"more_details\": {}}, {}),\n        ({\"level1\": {\"level2\": {\"value\": 0, \"another_value\": {}}}}, {}),\n        (\n            {\n                \"level1\": {\"level2\": {\"value\": 0, \"another_value\": 1}},\n                \"level3\": {\"empty_dict\": {}, \"valid_token\": 10},\n            },\n            {\"level1\": {\"level2\": {\"another_value\": 1}}, \"level3\": {\"valid_token\": 10}},\n        ),\n    ],\n)\ndef test_simplify_usage_dict(input_data, expected_output):\n    # This utility function is used by at least one plugin - llm-openai-plugin\n    assert simplify_usage_dict(input_data) == expected_output\n\n\n@pytest.mark.parametrize(\n    \"input,last,expected\",\n    [\n        [\"This is a sample text without any code blocks.\", False, None],\n        [\n            \"Here is some text.\\n\\n```\\ndef foo():\\n    return 'bar'\\n```\\n\\nMore text.\",\n            False,\n            \"def foo():\\n    return 'bar'\\n\",\n        ],\n        [\n            \"Here is some text.\\n\\n```python\\ndef foo():\\n    return 'bar'\\n```\\n\\nMore text.\",\n            False,\n            \"def foo():\\n    return 'bar'\\n\",\n        ],\n        [\n            \"Here is some text.\\n\\n````\\ndef foo():\\n    return 'bar'\\n````\\n\\nMore text.\",\n            False,\n            \"def foo():\\n    return 'bar'\\n\",\n        ],\n        [\n            \"Here is some text.\\n\\n````javascript\\nfunction foo() {\\n    return 'bar';\\n}\\n````\\n\\nMore text.\",\n            False,\n            \"function foo() {\\n    return 'bar';\\n}\\n\",\n        ],\n        [\n            \"Here is some text.\\n\\n```python\\ndef foo():\\n    return 'bar'\\n````\\n\\nMore text.\",\n            False,\n            None,\n        ],\n        [\n            \"First code block:\\n\\n```python\\ndef foo():\\n    return 'bar'\\n```\\n\\n\"\n            \"Second code block:\\n\\n```javascript\\nfunction foo() {\\n    return 'bar';\\n}\\n```\",\n            False,\n            \"def foo():\\n    return 'bar'\\n\",\n        ],\n        [\n            \"First code block:\\n\\n```python\\ndef foo():\\n    return 'bar'\\n```\\n\\n\"\n            \"Second code block:\\n\\n```javascript\\nfunction foo() {\\n    return 'bar';\\n}\\n```\",\n            True,\n            \"function foo() {\\n    return 'bar';\\n}\\n\",\n        ],\n        [\n            \"First code block:\\n\\n```python\\ndef foo():\\n    return 'bar'\\n```\\n\\n\"\n            # This one has trailing whitespace after the second code block:\n            # https://github.com/simonw/llm/pull/718#issuecomment-2613177036\n            \"Second code block:\\n\\n```javascript\\nfunction foo() {\\n    return 'bar';\\n}\\n``` \",\n            True,\n            \"function foo() {\\n    return 'bar';\\n}\\n\",\n        ],\n        [\n            \"Here is some text.\\n\\n```python\\ndef foo():\\n    return `bar`\\n```\\n\\nMore text.\",\n            False,\n            \"def foo():\\n    return `bar`\\n\",\n        ],\n    ],\n)\ndef test_extract_fenced_code_block(input, last, expected):\n    actual = extract_fenced_code_block(input, last=last)\n    assert actual == expected\n\n\n@pytest.mark.parametrize(\n    \"schema, expected\",\n    [\n        # Test case 1: Basic comma-separated fields, default string type\n        (\n            \"name, bio\",\n            {\n                \"type\": \"object\",\n                \"properties\": {\"name\": {\"type\": \"string\"}, \"bio\": {\"type\": \"string\"}},\n                \"required\": [\"name\", \"bio\"],\n            },\n        ),\n        # Test case 2: Comma-separated fields with types\n        (\n            \"name, age int, balance float, active bool\",\n            {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"name\": {\"type\": \"string\"},\n                    \"age\": {\"type\": \"integer\"},\n                    \"balance\": {\"type\": \"number\"},\n                    \"active\": {\"type\": \"boolean\"},\n                },\n                \"required\": [\"name\", \"age\", \"balance\", \"active\"],\n            },\n        ),\n        # Test case 3: Comma-separated fields with descriptions\n        (\n            \"name: full name, age int: years old\",\n            {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"name\": {\"type\": \"string\", \"description\": \"full name\"},\n                    \"age\": {\"type\": \"integer\", \"description\": \"years old\"},\n                },\n                \"required\": [\"name\", \"age\"],\n            },\n        ),\n        # Test case 4: Newline-separated fields\n        (\n            \"\"\"\n        name\n        bio\n        age int\n        \"\"\",\n            {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"name\": {\"type\": \"string\"},\n                    \"bio\": {\"type\": \"string\"},\n                    \"age\": {\"type\": \"integer\"},\n                },\n                \"required\": [\"name\", \"bio\", \"age\"],\n            },\n        ),\n        # Test case 5: Newline-separated with descriptions containing commas\n        (\n            \"\"\"\n        name: the person's name\n        age int: their age in years, must be positive\n        bio: a short bio, no more than three sentences\n        \"\"\",\n            {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"name\": {\"type\": \"string\", \"description\": \"the person's name\"},\n                    \"age\": {\n                        \"type\": \"integer\",\n                        \"description\": \"their age in years, must be positive\",\n                    },\n                    \"bio\": {\n                        \"type\": \"string\",\n                        \"description\": \"a short bio, no more than three sentences\",\n                    },\n                },\n                \"required\": [\"name\", \"age\", \"bio\"],\n            },\n        ),\n        # Test case 6: Empty schema\n        (\"\", {\"type\": \"object\", \"properties\": {}, \"required\": []}),\n        # Test case 7: Explicit string type\n        (\n            \"name str, description str\",\n            {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"name\": {\"type\": \"string\"},\n                    \"description\": {\"type\": \"string\"},\n                },\n                \"required\": [\"name\", \"description\"],\n            },\n        ),\n        # Test case 8: Extra whitespace\n        (\n            \"  name  ,  age   int  :  person's age  \",\n            {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"name\": {\"type\": \"string\"},\n                    \"age\": {\"type\": \"integer\", \"description\": \"person's age\"},\n                },\n                \"required\": [\"name\", \"age\"],\n            },\n        ),\n    ],\n)\ndef test_schema_dsl(schema, expected):\n    result = schema_dsl(schema)\n    assert result == expected\n\n\ndef test_schema_dsl_multi():\n    result = schema_dsl(\"name, age int: The age\", multi=True)\n    assert result == {\n        \"type\": \"object\",\n        \"properties\": {\n            \"items\": {\n                \"type\": \"array\",\n                \"items\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"name\": {\"type\": \"string\"},\n                        \"age\": {\"type\": \"integer\", \"description\": \"The age\"},\n                    },\n                    \"required\": [\"name\", \"age\"],\n                },\n            }\n        },\n        \"required\": [\"items\"],\n    }\n\n\n@pytest.mark.parametrize(\n    \"text, max_length, normalize_whitespace, keep_end, expected\",\n    [\n        # Basic truncation tests\n        (\"Hello, world!\", 100, False, False, \"Hello, world!\"),\n        (\"Hello, world!\", 5, False, False, \"He...\"),\n        (\"\", 10, False, False, \"\"),\n        (None, 10, False, False, None),\n        # Normalize whitespace tests\n        (\"Hello   world!\", 100, True, False, \"Hello world!\"),\n        (\"Hello \\n\\t world!\", 100, True, False, \"Hello world!\"),\n        (\"Hello   world!\", 5, True, False, \"He...\"),\n        # Keep end tests\n        (\"Hello, world!\", 10, False, True, \"He... d!\"),\n        (\"Hello, world!\", 7, False, False, \"Hell...\"),  # Now using regular truncation\n        (\"1234567890\", 7, False, False, \"1234...\"),  # Now using regular truncation\n        # Combinations of parameters\n        (\"Hello   world!\", 10, True, True, \"He... d!\"),\n        # Note: After normalization, \"Hello world!\" is exactly 12 chars, so no truncation\n        (\"Hello \\n\\t world!\", 12, True, True, \"Hello world!\"),\n        # Edge cases\n        (\"12345\", 5, False, False, \"12345\"),\n        (\"123456\", 5, False, False, \"12...\"),\n        (\"12345\", 5, False, True, \"12345\"),  # Unchanged for exact fit\n        (\"123456\", 5, False, False, \"12...\"),  # Regular truncation for small max_length\n        # Very long string\n        (\"A\" * 200, 10, False, False, \"AAAAAAA...\"),\n        (\"A\" * 200, 10, False, True, \"AA... AA\"),  # keep_end with adequate length\n        # Exact boundary cases\n        (\"123456789\", 9, False, False, \"123456789\"),  # Exact fit\n        (\"1234567890\", 9, False, False, \"123456...\"),  # Simple truncation\n        (\"123456789\", 9, False, True, \"123456789\"),  # Exact fit with keep_end\n        (\"1234567890\", 9, False, True, \"12... 90\"),  # keep_end truncation\n        # Minimum sensible length tests for keep_end\n        (\n            \"1234567890\",\n            8,\n            False,\n            True,\n            \"12345...\",\n        ),  # Too small for keep_end, use regular\n        (\"1234567890\", 9, False, True, \"12... 90\"),  # Just enough for keep_end\n    ],\n)\ndef test_truncate_string(text, max_length, normalize_whitespace, keep_end, expected):\n    \"\"\"Test the truncate_string function with various inputs and parameters.\"\"\"\n    result = truncate_string(\n        text=text,\n        max_length=max_length,\n        normalize_whitespace=normalize_whitespace,\n        keep_end=keep_end,\n    )\n    assert result == expected\n\n\n@pytest.mark.parametrize(\n    \"text, max_length, keep_end, prefix_len, expected_full\",\n    [\n        # Test cases when the length is just right (string fits)\n        (\"0123456789\", 10, True, None, \"0123456789\"),\n        # Test cases with enough room for the ellipsis\n        (\"012345678901234\", 14, True, 4, \"0123... 1234\"),\n        # Test cases with different cutoffs\n        (\"abcdefghijklmnopqrstuvwxyz\", 10, True, 2, \"ab... yz\"),\n        (\"abcdefghijklmnopqrstuvwxyz\", 12, True, 3, \"abc... xyz\"),\n        # Test cases below minimum threshold\n        (\"abcdefghijklmnopqrstuvwxyz\", 8, True, None, \"abcde...\"),\n    ],\n)\ndef test_test_truncate_string_keep_end(\n    text, max_length, keep_end, prefix_len, expected_full\n):\n    \"\"\"Test the specific behavior of the keep_end parameter.\"\"\"\n    result = truncate_string(\n        text=text,\n        max_length=max_length,\n        keep_end=keep_end,\n    )\n\n    assert result == expected_full\n\n    # Only check prefix/suffix when we expect truncation with keep_end\n    if prefix_len is not None and len(text) > max_length and max_length >= 9:\n        assert result[:prefix_len] == text[:prefix_len]\n        assert result[-prefix_len:] == text[-prefix_len:]\n        assert \"... \" in result\n\n\n@pytest.mark.parametrize(\n    \"content,expected_fenced\",\n    [\n        # Case 1: Contains many angle brackets (>10)\n        (\n            \"<div><p>Test</p><span>Test</span><a>Test</a><b>Test</b><i>Test</i><u>Test</u>\",\n            True,\n        ),\n        # Case 2: Short content with few angle brackets\n        (\"<p>Just a paragraph</p>\", False),\n        # Case 3: Many short lines (>3 lines, 90% under 120 chars)\n        (\"line1\\nline2\\nline3\\nline4\\nline5\", True),\n        # Case 4: Many long lines (>3 lines, <90% under 120 chars)\n        (\"x\" * 130 + \"\\n\" + \"x\" * 130 + \"\\n\" + \"x\" * 130 + \"\\n\" + \"x\" * 50, False),\n        # Case 5: Mixed case (many angle brackets and short lines)\n        (\"<div>\\n<p>Line 1</p>\\n<p>Line 2</p>\\n<p>Line 3</p>\\n</div>\", True),\n        # Case 6: Mixed case with few lines\n        (\"<div><p>Only two</p></div>\", False),\n        # Case 7: Empty string\n        (\"\", False),\n        # Case 8: Content with existing backticks (should use more backticks)\n        (\"```\\ndef test():\\n    pass\\n```\", True),\n    ],\n)\ndef test_maybe_fenced_code(content: str, expected_fenced: bool):\n    result = maybe_fenced_code(content)\n\n    if expected_fenced:\n        # Should be wrapped in fenced code block\n        assert result != content\n        assert result.strip().startswith(\"```\")\n        assert result.strip().endswith(\"```\")\n        assert content.strip() in result\n    else:\n        # Should remain unchanged\n        assert result == content\n\n\n@pytest.mark.parametrize(\n    \"content,backtick_count\",\n    [\n        # Content with no backticks should use 3 backticks\n        (\"def test():\\n    pass\", 3),\n        # Content with 3 backticks should use 4 backticks\n        (\"```\\ndef test():\\n    pass\\n```\", 4),\n        # Content with 4 backticks should use 5 backticks\n        (\"````\\ndef test():\\n    pass\\n````\", 5),\n    ],\n)\ndef test_backtick_count_adjustment(content: str, backtick_count: int):\n    # Force the content to be treated as code by adding many angle brackets\n    content_with_brackets = content + \"<\" * 11\n\n    result = maybe_fenced_code(content_with_brackets)\n\n    # Check if the correct number of backticks is used\n    expected_start = \"\\n\" + \"`\" * backtick_count + \"\\n\"\n    expected_end = \"\\n\" + \"`\" * backtick_count\n\n    assert result.startswith(expected_start)\n    assert result.endswith(expected_end)\n\n\nclass Files:\n    def __init__(self, dir=\".\"):\n        self.dir = dir\n\n\nclass ValueFlag:\n    def __init__(self, value=None, flag=False):\n        self.value = value\n        self.flag = flag\n\n\n@pytest.mark.parametrize(\n    \"spec, expected_cls, expected_attrs\",\n    [\n        (\"Files\", Files, {\"dir\": \".\"}),\n        (\"Files()\", Files, {\"dir\": \".\"}),\n        ('Files(\"tmp\")', Files, {\"dir\": \"tmp\"}),\n        ('Files({\"dir\": \"/tmp\"})', Files, {\"dir\": \"/tmp\"}),\n        ('Files(dir=\"/data\")', Files, {\"dir\": \"/data\"}),\n        (\n            'ValueFlag({\"value\": 123, \"flag\": true})',\n            ValueFlag,\n            {\"value\": 123, \"flag\": True},\n        ),\n        (\"ValueFlag(flag=true)\", ValueFlag, {\"flag\": True}),\n        (\"ValueFlag(value=123, flag=false)\", ValueFlag, {\"value\": 123, \"flag\": False}),\n    ],\n)\ndef test_instantiate_valid(spec, expected_cls, expected_attrs):\n    obj = instantiate_from_spec({\"Files\": Files, \"ValueFlag\": ValueFlag}, spec)\n    assert isinstance(obj, expected_cls)\n    for key, val in expected_attrs.items():\n        assert getattr(obj, key) == val\n\n\n@pytest.mark.parametrize(\n    \"spec\",\n    [\n        'Files({\"dir\":})',\n        \"Files(\",\n        \"Files(dir=)\",\n        'Files({\"dir\": [})',\n        \"Files(.)\",\n        \"Files(this is invalid)\",\n        \"ValueFlag(value=123, flag=falseTypo)\",\n    ],\n)\ndef test_instantiate_invalid(spec):\n    with pytest.raises(ValueError):\n        instantiate_from_spec({\"Files\": Files, \"ValueFlag\": ValueFlag}, spec)\n\n\ndef test_get_key(user_path, monkeypatch):\n    monkeypatch.setenv(\"ENV\", \"from-env\")\n    (user_path / \"keys.json\").write_text(json.dumps({\"testkey\": \"TEST\"}), \"utf-8\")\n    assert get_key(alias=\"testkey\") == \"TEST\"\n    assert get_key(input=\"testkey\") == \"TEST\"\n    assert get_key(alias=\"missing\", env=\"ENV\") == \"from-env\"\n    assert get_key(alias=\"missing\") is None\n    # found key should over-ride env\n    assert get_key(input=\"testkey\", env=\"ENV\") == \"TEST\"\n    # explicit key should over-ride alias\n    assert get_key(input=\"explicit\", alias=\"testkey\") == \"explicit\"\n    assert get_key(input=\"explicit\", alias=\"testkey\", env=\"ENV\") == \"explicit\"\n\n\ndef test_monotonic_ulids():\n    ulids = [monotonic_ulid() for i in range(1000)]\n    assert ulids == sorted(ulids)\n\n\ndef test_toolbox_config_capture():\n    \"\"\"Test that Toolbox captures __init__ parameters in _config\"\"\"\n\n    # Single positional arg\n    class Tool1(Toolbox):\n        def __init__(self, value):\n            pass\n\n    assert Tool1(42)._config == {\"value\": 42}\n\n    # Multiple positional args\n    class Tool2(Toolbox):\n        def __init__(self, a, b, c):\n            pass\n\n    assert Tool2(1, 2, 3)._config == {\"a\": 1, \"b\": 2, \"c\": 3}\n\n    # Keyword args with defaults\n    class Tool3(Toolbox):\n        def __init__(self, name=\"default\", count=10):\n            pass\n\n    assert Tool3()._config == {\"name\": \"default\", \"count\": 10}\n    assert Tool3(name=\"custom\", count=20)._config == {\"name\": \"custom\", \"count\": 20}\n\n    # Mixed args\n    class Tool4(Toolbox):\n        def __init__(self, required, optional=\"default\"):\n            pass\n\n    assert Tool4(\"hello\")._config == {\"required\": \"hello\", \"optional\": \"default\"}\n    assert Tool4(\"world\", optional=\"custom\")._config == {\n        \"required\": \"world\",\n        \"optional\": \"custom\",\n    }\n\n    # Var args excluded\n    class Tool5(Toolbox):\n        def __init__(self, regular, *args, **kwargs):\n            pass\n\n    assert Tool5(\"test\", 1, 2, extra=\"value\")._config == {\"regular\": \"test\"}\n\n    # No init\n    class Tool6(Toolbox):\n        pass\n\n    assert Tool6()._config == {}\n"
  }
]