This release is a pre-release and may not be stable for production use.
datasette-llm
LLM integration for Datasette plugins.
This plugin provides a standard interface for Datasette plugins to use LLM models via the llm library, with:
- Model management: Control which models are available, with filtering, defaults, and model options
- API key management: Integration with datasette-secrets for secure key storage
- Hooks for extensibility: Track usage, enforce policies, implement accounting
See the unreleased changes and compatibility notes before upgrading.
Installation
Install this plugin in the same environment as Datasette:
datasette install datasette-llm
You'll also need at least one LLM model plugin installed:
# For OpenAI models
datasette install llm
# For Anthropic models
datasette install llm-anthropic
# For testing without API calls
datasette install llm-echo
Configuration
Configure the plugin in your datasette.yaml:
plugins:
datasette-llm:
# Default model when none specified
default_model: gpt-5.4-mini
# Purpose-specific configuration
purposes:
enrichments:
model: gpt-5.4-nano # Default model for bulk operations
sql-assistant:
model: gpt-5.4 # Smarter for complex queries
models: # Only these models for SQL assistance
- gpt-5.4
- gpt-5.4-mini
extract:
model: claude-sonnet-4.6
blocked_models: # Block specific models for extraction
- gpt-5.4-pro
chat:
model: claude-sonnet-4.6
# Model availability (optional)
models: # Allowlist - only these models available
- gpt-5.4
- gpt-5.4-mini
- gpt-5.4-nano
- claude-sonnet-4.6
# Or use a blocklist instead
blocked_models:
- gpt-5.4-pro # Too expensive
# Only show models with API keys configured (default: true)
require_keys: true
Model references with custom API keys and options
Anywhere a model name string is accepted in configuration (default_model, purposes.<name>.model, or entries in global or purpose-specific models lists), you can use a dictionary instead. Use global or purpose-specific endpoints lists to register additional endpoint dictionaries without enabling an allowlist. Dictionaries are also accepted by llm.model() and llm.group().
The dictionary supports these fields:
model: Required model ID, e.g."gpt-5.4-mini".key: Optional datasette-secrets secret name or stored LLM key alias to use as the API key for that model.options: Optional dictionary of default prompt options for that model. These are passed through to the underlying llm model plugin, so supported option names depend on the selected model.
plugins:
datasette-llm:
# Default model with a custom key and default options
default_model:
model: gpt-5.4-mini
key: CUSTOM_OPENAI_KEY
options:
temperature: 0.2
purposes:
# Pin a purpose to one model with its own billing key and options
query-assistant:
model:
model: gpt-5.4-mini
key: QUERY_ASSISTANT_KEY
options:
temperature: 0
# Multiple models, each with their own key and/or options
enrichments:
model:
model: gpt-5.4-nano
key: ENRICHMENTS_NANO_KEY
options:
max_tokens: 1000
models:
- model: gpt-5.4
key: ENRICHMENTS_GPT5_KEY
- model: gpt-5.4-mini
key: ENRICHMENTS_MINI_KEY
options:
temperature: 0.1
- claude-sonnet-4.6 # Falls through to default key resolution
Configured options become defaults for calls made through await llm.model() or llm.group(). Per-call options override configured defaults:
model = await llm.model()
# Uses temperature: 0.2 from datasette.yaml
response = await model.prompt("Suggest three tags")
# Overrides the configured temperature for this one prompt
response = await model.prompt("Suggest three tags", temperature=0.7)
For chained prompts, pass per-call overrides in the options= dictionary:
responses = model.chain(
"Suggest three tags",
options={"temperature": 0.7},
)
The key field is resolved through datasette-secrets first, so you can set it via environment variables:
export DATASETTE_SECRETS_QUERY_ASSISTANT_KEY=sk-...
export DATASETTE_SECRETS_ENRICHMENTS_NANO_KEY=sk-...
If that secret is unavailable, the same name is looked up in LLM's stored keys. For example, key: openrouter uses the key saved with llm keys set openrouter. An explicitly named key that is missing from both sources raises llm.NeedsKeyException when selected, and the model is excluded from lists when require_keys is true. A missing alias is never sent as a literal key and never falls back to another provider credential.
The configured key name and default options are selected from the first matching reference that supplies the setting:
- A dictionary passed directly to
llm.model()orllm.group() - A matching dictionary in the purpose's
modelslist - The purpose's default
modeldictionary - A matching dictionary in the purpose's
endpointslist - The global
default_modeldictionary - A matching dictionary in the global
modelslist - A matching dictionary in the global
endpointslist
For installed models without an explicit key name, standard provider key resolution applies: datasette-secrets (<PROVIDER>_API_KEY), then LLM's stored provider key or environment variable.
The blocked_models fields remain lists of model ID strings.
OpenAI-compatible endpoints
Add api_base to a model reference to use an arbitrary OpenAI-compatible Chat Completions or Responses endpoint. This requires no additional LLM plugin or changes to LLM's model configuration file. For example, use Qwen3.8 27B served by LM Studio through its local Responses API:
plugins:
datasette-llm:
default_model: lmstudio-qwen
endpoints:
- model: lmstudio-qwen
model_name: qwen/qwen3.8-27b
api_base: http://localhost:1234/v1
responses: true
- model: hosted-assistant
model_name: organization/assistant
api_base: https://models.example.com/v1
responses: true
key: openrouter
options:
temperature: 0.2
purposes:
sql-assistant:
model: hosted-assistant
endpoints registers models without restricting the installed models available to Datasette. The models setting remains an allowlist: if you configure it, registered endpoints must be allowed there or selected as the configured default. You can also place an endpoint dictionary directly in a default or a models list when those filtering semantics are intended. A purpose-specific endpoints list registers models only for that purpose.
model is the local ID used by Datasette model pickers, hooks, allowlists and blocklists. model_name is the ID sent to the endpoint and defaults to model. Use distinct local IDs for different endpoints serving the same remote model, or to avoid a collision with an installed model or alias.
api_base is the HTTP or HTTPS base URL, including any required path prefix such as /v1. Chat Completions requests append /chat/completions; set responses: true to append /responses instead. URLs with embedded credentials, query strings or fragments are rejected.
Endpoint models support tool calling by default. Pass tools through the existing chain API:
def double(number: int) -> int:
return number * 2
model = await llm.model("hosted-assistant")
chain = model.chain("What is double 21?", tools=[double])
async for response in chain.responses():
print(await response.text())
The endpoint must support the selected protocol and any capabilities you use. Additional endpoint fields follow LLM's configuration conventions: supports_tools and can_stream default to true; supports_schema, vision, audio and reasoning default to false. Set can_stream: false for a server that requires non-streaming requests. headers accepts a dictionary of extra HTTP headers. Reasoning summaries are only requested when explicitly selected through prompt options.
Put credentials in key, using a Datasette secret or stored LLM key alias. Do not put credentials such as Authorization: Bearer ... in headers: those values can be visible to anonymous users at /-/config.json.
An endpoint without key is treated as unauthenticated and remains available with require_keys: true. It never receives the default OpenAI key. To use an existing stored LLM key, set key to its alias, such as openrouter after running llm keys set openrouter. Setting key: openai deliberately selects that named key; it is never selected implicitly. Datasette-secrets takes precedence over the stored LLM alias.
Definitions in global endpoints, the global default or the global models list are available globally. Definitions appearing only under a purpose are available for that purpose. A model defined in the active configuration can be selected by its local ID, including from an llm_default_model hook. Repeated definitions of an ID must agree on endpoint, remote model name, protocol and capabilities; keys and prompt options can vary by purpose. An invalid reference raises ModelConfigurationError from the lookups that use it: every lookup if it is global, or only that purpose's lookups if it appears under a purpose. It does not prevent other plugins from using datasette-secrets. Conflicting endpoints, including IDs that clash with an installed alias, are omitted from listings; selecting that endpoint raises ModelConfigurationError. Other installed models remain usable.
You can also provide an endpoint directly in Python:
model = await llm.model({
"model": "lmstudio-qwen",
"model_name": "qwen/qwen3.8-27b",
"api_base": "http://localhost:1234/v1",
"responses": True,
})
The same dictionary works with llm.group(). Direct definitions apply to that call and are not registered with LLM or added to later model listings. All model selection paths apply the configured allowlists, blocklists, key requirements and filter hooks.
Falling back to LLM's default model
If neither default_model nor a purpose-specific model is configured, datasette-llm falls back to LLM's own default model - the model set using llm models default <model>, or LLM's built-in default if none has been set.
This fallback is only used if that model is available to Datasette: it must have an API key (unless require_keys is false) and must not be excluded by the models or blocked_models settings or by an llm_filter_models hook. Unlike default_model, LLM's default model is not automatically added to the models allowlist. If the fallback model is not available, calling await llm.model() without a model ID raises a ModelNotFoundError.
Model filtering
The models and blocked_models keys control which models are available. Use models to define an allowlist (only these models will be available) or blocked_models to define a blocklist (all models except these will be available). If both are set, the allowlist is applied first and the blocklist removes from the result. The default_model is automatically included in the allowlist, so you don't need to repeat it there. These restrictions and llm_filter_models hooks apply to both listing and selecting models, including explicit IDs and configured defaults. Selecting an excluded model raises ModelNotFoundError.
Purpose-specific configuration
Plugins register purposes to describe what they use LLM models for (e.g. "extract", "enrichments", "sql-assistant"). Each purpose can have its own configuration under purposes.<name>:
model: The default model for this purpose, used when a plugin callsawait llm.model(purpose="extract")without specifying a model ID.models: Additional allowed models for this purpose, combined with the globalmodelsallowlist. A model can be available for a specific purpose even if it is not in the global list. The purpose's defaultmodeland globaldefault_modelare automatically included, so you don't need to repeat them. Purpose models are listed before remaining global models, with the selected default first.blocked_models: A blocklist of models for this purpose. These are removed even if the model is globally allowed.
When no purpose-specific models list is set, the global models allowlist applies. The global blocked_models always applies regardless of purpose configuration.
API Key Management
datasette-llm integrates with datasette-secrets for API key management. Keys are automatically registered for all installed model providers.
Setting up keys
-
Via environment variables (recommended for deployment):
export DATASETTE_SECRETS_OPENAI_API_KEY=sk-... export DATASETTE_SECRETS_ANTHROPIC_API_KEY=sk-ant-...
-
Via the web interface: Navigate to
/-/secrets(requiresmanage-secretspermission) -
Via llm CLI (fallback): Keys set with
llm keys set openaiare also used
Key resolution order
- datasette-secrets (env var
DATASETTE_SECRETS_<PROVIDER>_API_KEYor encrypted database) - llm's keys.json (
~/.config/io.datasette.llm/keys.json) - llm's environment variables (e.g.,
OPENAI_API_KEY)
Usage
All examples below assume you have created an LLM instance:
from datasette_llm import LLM
llm = LLM(datasette)
llm.model()
async def model(
model_id: Optional[str | dict] = None,
purpose: Optional[str] = None,
actor: Optional[dict] = None,
) -> WrappedAsyncModel
Get an async model wrapped with hook support. Returns a WrappedAsyncModel that invokes hooks around prompts.
Parameters:
model_id(Optional[str | dict]): The model ID or model-reference dictionary to use, e.g."gpt-5.4-mini"or an endpoint reference. If not provided, the default model is resolved from configuration — first checking the purpose-specificmodelsetting, then the globaldefault_model, then LLM's own default model.purpose(Optional[str]): Identifies what this model will be used for, e.g."enrichments","sql-assistant". This selects the purpose-specific default model and is passed through to hooks likellm_prompt_contextfor auditing and accounting.actor(Optional[dict]): The Datasette actor dictionary for the current user. Pass this to enable two things: per-user model filtering, where plugins using thellm_filter_modelsorllm_default_modelhooks can restrict or customize models based on who is making the request; and audit logging, where auditing plugins that implementllm_prompt_contextcan record which actor ran which prompts.
async def my_plugin_view(datasette, request):
llm = LLM(datasette)
# Get the default model
model = await llm.model()
# Specify a model explicitly
model = await llm.model("gpt-5.4-mini")
# With purpose and actor for auditing and filtering
model = await llm.model(
purpose="sql-assistant",
actor=request.actor,
)
# Execute a prompt
response = await model.prompt("What is the capital of France?")
text = await response.text()
llm.models()
async def models(
actor: Optional[dict] = None,
purpose: Optional[str] = None,
) -> List
Get available models, filtered by configuration, API key availability, and hooks. Returns a list of model objects.
Parameters:
actor(Optional[dict]): The Datasette actor dictionary. When provided, thellm_filter_modelshook can use this to return only models the actor is allowed to use — for example, restricting anonymous users to cheaper models, or looking up per-user model allowlists in a database. Auditing plugins can also use this to log which actors are querying model availability.purpose(Optional[str]): When provided, purpose-specificmodelsandblocked_modelsconfiguration is applied. A purpose-specificmodelsallowlist is combined with the global allowlist, so models can be made available for a specific purpose even if they aren't globally listed.
llm = LLM(datasette)
# Get all available models (filtered by config and key availability)
models = await llm.models()
for model in models:
print(model.model_id)
# Filter by actor (for per-user permissions)
models = await llm.models(actor=request.actor)
# Filter by purpose (applies purpose-specific models/blocked_models config)
models = await llm.models(purpose="enrichments")
# Both together — e.g. to populate a model picker for the current user
models = await llm.models(actor=request.actor, purpose="sql-assistant")
Streaming responses
model = await llm.model("gpt-5.4-mini")
response = await model.prompt("Tell me a story")
# Non-streaming - wait for complete response
text = await response.text()
# Streaming - process chunks as they arrive
async for chunk in response:
print(chunk, end="", flush=True)
Grouping prompts
Use group() for batch operations where multiple prompts are logically related:
async def enrich_rows(datasette, rows):
llm = LLM(datasette)
# Model determined by purpose configuration
async with llm.group(purpose="enrichments") as model:
results = []
for row in rows:
response = await model.prompt(f"Summarize: {row['content']}")
text = await response.text()
results.append(text)
# All responses guaranteed complete here
return results
Benefits of group():
- Transactional semantics: All responses forced to complete on exit
- Shared context: Hooks can treat grouped prompts together (e.g., shared budget reservation)
- Cleanup: The
llm_group_exithook is called for settlement/logging
Plugin Hooks
datasette-llm provides hooks for other plugins to extend LLM operations.
llm_prompt_context
Wrap prompt execution with custom logic. The same hook is used for direct
prompt() calls and for each response yielded by chain():
from datasette import hookimpl
from contextlib import asynccontextmanager
@hookimpl
def llm_prompt_context(datasette, model_id, prompt, purpose, actor):
@asynccontextmanager
async def wrapper(result):
# Before the prompt executes
actor_id = actor.get("id") if actor else None
print(f"Starting prompt to {model_id} by {actor_id}")
yield
# After prompt() returns or chain() is initialized
async def on_complete(response):
usage = await response.usage()
print(f"Used {usage.input} input, {usage.output} output tokens")
await result.on_response_done(on_complete)
return wrapper
prompt is the text of the new input. When a caller passes its whole input
as messages= with no prompt text, prompt is the text of the final
message, provided that is a user message. It is None when there is no new
user input, for example a chain resumed from a history that ends in
unresolved tool calls.
result.response continues to expose the first response, while
result.responses contains all responses seen so far. The
await result.on_response_done(callback) helper attaches a callback to all
existing responses and any future responses produced by a chain.
llm_group_exit
Called when a group() context manager exits:
@hookimpl
def llm_group_exit(datasette, group):
# Can return a coroutine for async cleanup
async def cleanup():
print(f"Group for {group.purpose} completed")
print(f"Processed {len(group._responses)} prompts")
return cleanup()
register_llm_purposes
Register purpose strings that your plugin uses, along with documentation explaining what they mean.
from datasette import hookimpl
from datasette_llm import Purpose
@hookimpl
def register_llm_purposes(datasette):
return [
Purpose(
name="query-assistant",
description="Assists users with writing SQL queries",
),
Purpose(
name="suggest-table-names",
description="Suggests names for tables based on imported CSV files",
),
]
Registered purposes can be retrieved by other plugins (e.g., to build an admin UI for model assignment):
from datasette_llm import get_purposes
purposes = get_purposes(datasette)
for purpose in purposes:
print(f"{purpose.name}: {purpose.description}")
If multiple plugins register the same purpose name, the first registration wins.
llm_filter_models
Influence the models that are returned from the await llm.models() method. Plugins can use this to add custom logic informing which models are available, taking into account both the actor and the purpose of the prompt.
modelsis a list of available model objects from all of the installed LLM plugins.actoris an actor dictionary orNonepurposeis a purpose string orNone
The actor and purpose are the ones that were passed to the llm.models(actor=..., purpose=...) method.
All filter hooks receive the same initial list of models after API key filtering. Their returned lists are intersected by model ID, so a model must be allowed by every hook that returns a list. Return None to leave availability unchanged, or an empty list to make no models available. A hook cannot restore a model excluded by another hook.
@hookimpl
async def llm_filter_models(datasette, models, actor, purpose):
if not actor:
# Anonymous users get limited models
return [m for m in models if m.model_id == "gpt-5.4-mini"]
# Check database for user's allowed models
db = datasette.get_database()
result = await db.execute(
"SELECT model_id FROM user_models WHERE user_id = ?",
[actor["id"]]
)
allowed = {row["model_id"] for row in result.rows}
return [m for m in models if m.model_id in allowed]
llm_default_model
This plugin hook is used when await llm.model() is called without any arguments - or with a purpose and/or actor specified. Plugins can use this to control which default model is used, including for a given purpose.
@hookimpl
async def llm_default_model(datasette, purpose, actor):
if actor:
# Check user's preferred model
db = datasette.get_database()
result = await db.execute(
"SELECT preferred_model FROM user_prefs WHERE user_id = ?",
[actor["id"]]
)
row = result.first()
if row:
return row["preferred_model"]
return None # Use config defaults
Related Plugins
- datasette-secrets: Secure API key storage (required dependency)
- datasette-llm-accountant: Budget management and cost tracking
Development
To set up this plugin locally:
cd datasette-llm
uv sync
# Confirm the plugin is visible
uv run datasette plugins
To run the tests:
uv run pytest
The test suite uses the llm-echo model which echoes back prompts without making API calls.
Metadata
Release files for datasette-llm 0.1a11
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| datasette_llm-0.1a11.tar.gz | 47.8 kB | With provenance | Details for datasette_llm-0.1a11.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| datasette_llm-0.1a11-py3-none-any.whl | Python 3 | none | any | Details for datasette_llm-0.1a11-py3-none-any.whl |
Total release size: 74.5 kB
Release files / datasette_llm-0.1a11.tar.gz
| Download URL | datasette_llm-0.1a11.tar.gz |
|---|---|
| Size | 47.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b055f30cb58549b6c8505c303cb040f12c9d37861eed216c72ecc1bc45221d03
|
|
BLAKE2b-256 checksum How to use checksums |
c960c4488fa2b82027490f48ea2d1ebf98833c62948cf2386342734619ed8ba5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 15, 2026.
Transparency logRelease files / datasette_llm-0.1a11-py3-none-any.whl
| Download URL | datasette_llm-0.1a11-py3-none-any.whl |
|---|---|
| Size | 26.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c6c8be1a0e1ee09c4567e1ec70ba86d73ce5d9100b7057ade4c525f654c86b08
|
|
BLAKE2b-256 checksum How to use checksums |
aa6c0cfdd59d368f20b1003f8eeb65188f011774c8567df0bc292973fd81a323
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 15, 2026.
Transparency logSecurity for datasette-llm 0.1a11
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for datasette_llm-0.1a11.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 15, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.