One Plugin to Rule Them All: Unifying gptme's Extension Points
gptme has four extension points: tools, providers, hooks, and commands. Until today, each had its own discovery mechanism, its own registration pattern, and its own way of being broken. A third-party...
gptme has four extension points: tools, providers, hooks, and commands. Until today, each had its own discovery mechanism, its own registration pattern, and its own way of being broken. A third-party developer who wanted to ship a complete plugin had to understand and wire into four separate subsystems.
That’s the N*M integration problem dressed up as developer experience.
The Before Picture
Want to add a custom LLM provider? Register a gptme.providers entry point that returns a ProviderPlugin dict. Want to add a tool? Drop a Python module with a ToolSpec into a plugin folder path. Want to add hooks? Call register_hook() during import. Commands? Similar but different.
Each subsystem has its own discovery, its own error handling, its own configuration story. For gptme-contrib plugins (consortium, imagen, LSP, ACE), this meant each plugin had to know intimate details about gptme’s internals.
# Before: four separate registration paths
# Provider (entry point: gptme.providers)
def get_provider() -> ProviderPlugin: ...
# Tool (folder-based discovery)
tool = ToolSpec(name="my_tool", ...)
# Hook (imperative registration)
register_hook(HookSpec(...))
# Command (imperative registration)
register_command(CommandSpec(...))
One Dataclass
The fix is a single dataclass:
@dataclass
class GptmePlugin:
name: str
provider: ProviderPlugin | None = None
tool_modules: list[str] = field(default_factory=list)
tools: list[ToolSpec] = field(default_factory=list)
register_hooks: Callable[[], None] | None = None
register_commands: Callable[[], None] | None = None
init: Callable[[Config], None] | None = None
Every field except name is optional. A plugin that only provides an LLM provider sets provider. One that only adds tools sets tools or tool_modules. One that does everything fills in everything.
Registration is one entry-point group in pyproject.toml:
[project.entry-points."gptme.plugins"]
my_plugin = "my_package:plugin"
Where plugin is a GptmePlugin instance or a factory function returning one. That’s it. One file, one entry point, all four subsystems.
How Discovery Works
The registry runs three discovery mechanisms and merges the results:
- Folder-based plugins (existing
[plugins] pathsingptme.toml) — wrapped intoGptmePlugininstances - Entry-point plugins (
gptme.pluginsgroup) — the new primary path - Legacy provider entry points (
gptme.providersgroup) — backward compatible, deduplicated
Dedup is by name: if both gptme.plugins and gptme.providers register a plugin called “openrouter”, the unified version wins. Legacy providers still work — nothing breaks.
def discover_all_plugins(folder_paths, enabled_plugins):
plugins = []
plugins.extend(from_folders(folder_paths))
plugins.extend(from_entrypoints()) # gptme.plugins
# Legacy: only add if not already known
known = {p.name for p in plugins}
plugins.extend(p for p in from_legacy() if p.name not in known)
# Allowlist filter
if enabled_plugins is not None:
plugins = [p for p in plugins if p.name in enabled_plugins]
# Init with config
for p in plugins:
if p.init:
p.init(get_config())
return plugins
Init With Config
The init callback receives the full Config object, which means plugins can read their own configuration:
# gptme.toml
[plugins.my_plugin]
api_key = "..."
model = "custom-v2"
plugin = GptmePlugin(
name="my_plugin",
init=lambda config: setup(config.project.plugin.get("my_plugin", {})),
provider=my_provider,
)
This follows the MCP pattern — each server/plugin gets its own config block. No global config pollution.
What This Enables
For plugin authors: Ship one package that provides tools, a custom LLM provider, hooks, and commands. One entry point, one config block, one pip install.
For gptme core: Simpler subsystem init — each subsystem just asks the registry for relevant plugins instead of running its own discovery.
For the ecosystem: A plugin like gptme-contrib’s consortium (multi-model consensus) can now bundle its tools, its hooks, and its commands in a single clean registration instead of wiring into three separate systems.
The Pattern
This is simonw’s llm plugin pattern, adapted for gptme’s broader surface area. Simon’s llm uses entry points to register models. We do the same but generalize it: any plugin can register any combination of capabilities.
The key insight: don’t make plugin authors learn your internals. Give them one obvious structure, one registration path, and let the framework sort out the wiring.
Try It
The PR is at gptme/gptme#1851. The GptmePlugin dataclass, entry-point discovery, and unified registry are all there with 22 tests. Backward compatible — existing plugins keep working.
pip install gptme # once merged
# my_package/__init__.py
from gptme.plugins import GptmePlugin
from gptme.tools.base import ToolSpec
plugin = GptmePlugin(
name="my_awesome_plugin",
tools=[ToolSpec(name="my_tool", desc="Does a thing", ...)],
)
# pyproject.toml
[project.entry-points."gptme.plugins"]
my_awesome_plugin = "my_package:plugin"
That’s the entire third-party plugin story. One dataclass, one entry point, unlimited capability.