Plugin System
Two Layers
The plugin system has two layers:
| Layer | Audience | Purpose |
|---|---|---|
| Registry (internal) | Plugin authors | Per-interface registries with type-safe registration |
| Client (user-facing) | Application developers | Single facade that wires providers together |
Layer 1: Per-Interface Registries
Each interface has its own registry. This ensures type safety — a vector database cannot be accidentally registered as an LLM provider.
LLM Registry (pkg/llm/)
// llm/registry.go
type ChatFactory func(cfg Config) (ChatCompleter, error)
func Register(name string, factory ChatFactory)
func Open(name string, opts ...Option) (ChatCompleter, error)
Embedding Registry (pkg/embedding/)
// embedding/registry.go
type EmbedderFactory func(cfg Config) (Embedder, error)
func Register(name string, factory EmbedderFactory)
func Open(name string, opts ...Option) (Embedder, error)
Vector Store Registry (pkg/vectordb/)
// vectordb/registry.go
type SearcherFactory func(cfg Config) (VectorSearcher, error)
func Register(name string, factory SearcherFactory)
func Open(name string, opts ...Option) (VectorSearcher, error)
How Providers Register
A single Go module can register into multiple registries. For example, OpenAI provides both LLM and Embedding:
// plugins/openai/openai.go
package openai
import (
"github.com/bachtiarpanjaitan/ihandai-go/pkg/llm"
"github.com/bachtiarpanjaitan/ihandai-go/pkg/embedding"
)
func init() {
llm.Register("openai", newChatCompleter)
embedding.Register("openai", newEmbedder)
}
Users activate providers with blank imports (following database/sql convention):
import (
_ "github.com/bachtiarpanjaitan/ihandai-go-plugins/openai"
_ "github.com/bachtiarpanjaitan/ihandai-go-plugins/ollama"
_ "github.com/bachtiarpanjaitan/ihandai-go-plugins/qdrant"
)
Layer 2: Client Facade (Root Package)
The Client provides a unified interface that discovers registered providers and wires them together:
import "github.com/bachtiarpanjaitan/ihandai-go"
ai := ihandai.New(
ihandai.WithLLM("openai",
llm.WithModel("gpt-4o"),
llm.WithAPIKey(os.Getenv("OPENAI_API_KEY")),
),
ihandai.WithEmbedding("ollama",
embedding.WithModel("nomic-embed-text"),
embedding.WithBaseURL("http://localhost:11434"),
),
ihandai.WithIndexEmbedding("ollama", // optional: different provider for indexing
embedding.WithModel("nomic-embed-text"),
),
ihandai.WithVectorStore("qdrant",
vectordb.WithURL("http://localhost:6333"),
),
)
Behind the scenes, ihandai.New() calls llm.Open("openai", ...), embedding.Open("ollama", ...), etc.
Two Usage Modes
Mode 1: Convenience (90% use case)
One client, one-liner calls. Framework handles the pipeline.
ai := ihandai.New(
ihandai.WithLLM("openai", ...),
ihandai.WithEmbedding("ollama", ...),
)
resp, _ := ai.Ask(ctx, "What is RAG?")
Mode 2: Advanced (full control)
Access individual providers directly for custom pipelines.
// Direct provider access — bypass the pipeline
chat, _ := ai.LLM() // ChatCompleter
embed, _ := ai.Embedding() // Embedder
store, _ := ai.VectorStore() // VectorSearcher
// Or even bypass the Client entirely
chat, _ := llm.Open("openai", llm.WithModel("gpt-4o"))
embed, _ := embedding.Open("ollama", embedding.WithModel("nomic-embed-text"))
Provider Categories
LLM Providers
| Name | Type | Interface |
|---|---|---|
| OpenAI | Cloud | ChatCompleter, StreamCompleter, TokenCounter |
| Anthropic | Cloud | ChatCompleter, StreamCompleter |
| Google Gemini | Cloud | ChatCompleter, StreamCompleter |
| Ollama | Local | ChatCompleter, StreamCompleter |
| Groq | Cloud | ChatCompleter, StreamCompleter |
| Azure OpenAI | Cloud | ChatCompleter, StreamCompleter |
Embedding Providers
| Name | Type | Interface |
|---|---|---|
| OpenAI | Cloud | Embedder |
| Ollama | Local | Embedder |
| Cohere | Cloud | Embedder |
| HuggingFace | Local/Cloud | Embedder |
| Voyage AI | Cloud | Embedder |
Vector Store Providers
| Name | Type | Interface |
|---|---|---|
| Qdrant | Self-hosted / Cloud | VectorSearcher, VectorInserter, VectorDeleter |
| pgvector | PostgreSQL extension | VectorSearcher, VectorInserter, VectorDeleter |
| Milvus | Self-hosted / Cloud | VectorSearcher, VectorInserter, VectorDeleter |
| Pinecone | Cloud | VectorSearcher, VectorInserter, VectorDeleter |
| Weaviate | Self-hosted / Cloud | VectorSearcher, VectorInserter, VectorDeleter |
| Chroma | Local | VectorSearcher, VectorInserter, VectorDeleter |
Registry Concurrency
Registries use sync.RWMutex — reads (lookups during Open()) are lock-free in the
common case. Writes (Register() via init()) happen once at program startup, before
any goroutines access the registry.
var (
registry = make(map[string]ChatFactory)
mu sync.RWMutex
)
func Register(name string, factory ChatFactory) {
mu.Lock()
defer mu.Unlock()
registry[name] = factory
}
func Open(name string, opts ...Option) (ChatCompleter, error) {
mu.RLock()
factory, ok := registry[name]
mu.RUnlock()
if !ok {
return nil, fmt.Errorf("llm: unknown provider %q (did you import the plugin?)", name)
}
return factory(buildConfig(opts))
}
Registry per-Interface Summary
| Package | Register Signature | Open Signature |
|---|---|---|
pkg/llm/ |
Register(name, ChatFactory) |
Open(name, ...Option) (ChatCompleter, error) |
pkg/embedding/ |
Register(name, EmbedderFactory) |
Open(name, ...Option) (Embedder, error) |
pkg/vectordb/ |
Register(name, SearcherFactory) |
Open(name, ...Option) (VectorSearcher, error) |
Future registries for Tool, Reranker, etc. will follow the same pattern.