Providers
Extend Dhaga with your own LLM, web search, embedding model, or vector store — all through provider-agnostic gateways in @dhaga/core.
Dhaga's provider contracts are exported from @dhaga/core. A provider can
live in this monorepo or in an independent npm package; application features
only consume the contracts and never import a vendor SDK directly.
Register external packages in apps/web/src/dhaga.providers.ts. Next.js runs
this bootstrap once before the Node.js server accepts requests. Provider SDKs
therefore remain optional dependencies and are only bundled when imported.
Writing a provider from scratch?
This page is the operator-facing overview. For step-by-step guides grounded in the actual interface signatures, see Extending Dhaga — Add an LLM provider, Add a search provider, and Add a vector store.
LLM providers
Implement LLMClient, then export an LLMProvider registration:
import type { LLMClient, LLMProvider } from "@dhaga/core";
const client: LLMClient = {
async extract(options) {
// Call the model, then validate before returning.
const value = options.schema.parse(await callModel(options));
return { data: value, model: "my-model", usage: { inputTokens: 0, outputTokens: 0 } };
},
async complete(options) {
const text = await callModel(options);
return { data: text, model: "my-model", usage: { inputTokens: 0, outputTokens: 0 } };
},
};
export const myLLM: LLMProvider = {
id: "my-llm",
capabilities: {
structuredOutput: true,
vision: false,
webSearch: false,
batch: false,
},
isConfigured: () => Boolean(process.env.MY_LLM_API_KEY),
createClient: () => client,
};Register and select it in the bootstrap:
import { registerLLMProvider, selectLLMProvider } from "@dhaga/core";
import { myLLM } from "@your-scope/dhaga-llm";
registerLLMProvider(myLLM);
selectLLMProvider(myLLM.id);Selection can instead use LLM_PROVIDER=my-llm. A provider declaring
batch: true must also expose createBatchClient; invalid registrations fail
at startup.
The built-in openai provider accepts OPENAI_BASE_URL, so many local or
OpenAI-compatible servers need no plugin. Compatible servers must support the
structured-output behavior used by extract, not only basic chat completions.
Search providers
Search has a deliberately small contract:
import type { SearchProvider } from "@dhaga/core";
export const searxng: SearchProvider = {
id: "searxng",
isConfigured: () => Boolean(process.env.SEARXNG_URL),
createClient: () => ({
async search(query, options) {
// Return normalized { title, url, snippet } records.
return searchSearxng(query, options?.limit ?? 5);
},
}),
};Use registerSearchProvider, then either selectSearchProvider("searxng")
or SEARCH_PROVIDER=searxng.
Two providers ship built in:
id | Implementation | Configured when |
|---|---|---|
anthropic | AnthropicSearchClient — Anthropic's own server-side web_search tool, run on the extract-tier model | ANTHROPIC_API_KEY is set |
firecrawl | FirecrawlSearchClient — Firecrawl's search API | FIRECRAWL_API_KEY is set |
Which one runs, most specific first: an explicit selectSearchProvider(id)
→ SEARCH_PROVIDER → FIRECRAWL_API_KEY set ? firecrawl : anthropic.
So Firecrawl stays the provider on any instance that actually configured it —
an existing self-host keeps its behaviour with no config change and no surprise
switch to a differently-metered one. Everywhere else the default is Anthropic,
which means hasSearch() is true on any instance that already set
ANTHROPIC_API_KEY for the other AI features. With neither key set the default
is still anthropic, so the error a caller eventually sees names
ANTHROPIC_API_KEY — the key that would actually fix it.
Wired, not yet proven
The anthropic provider has never been exercised against a live key: it
typechecks and is unit-tested against recorded response shapes, but no
end-to-end search has been observed. It is also billed twice over — $10 per
1,000 searches and every retrieved page charged as input tokens to the
searching model — so the published cost figures for the watchlist sweep are
estimates, not measurements.
Reporting what a search cost (optional)
A provider may additionally implement MeteredSearchClient, which adds one
method returning the results and their cost:
import type { MeteredSearchClient } from "@dhaga/core";
const client: MeteredSearchClient = {
async search(query, options) { /* … */ },
async searchMetered(query, options) {
return {
results: await this.search(query, options),
// `model` + `tokens` only when the search ran THROUGH a model.
usage: { searches: 1 },
};
},
};This is a capability, not a requirement — kept separate from SearchClient
for the same Interface-Segregation reason BatchLLMClient is kept separate from
LLMClient. A SearXNG or Brave client has no per-search inference bill to report
and must not be forced to invent one. Callers feature-detect with
isMeteredSearchClient(client) and meter only when they can, so a plain
SearchClient still needs zero changes anywhere else.
It exists because the built-in providers cost money in different currencies: Firecrawl bills a flat subscription outside Dhaga's metering entirely, while Anthropic's web search charges retrieved pages as model input tokens — a real inference bill the instance dollar ceiling has to see.
Embedding providers and vector stores
Embedding generation and storage are independent plugins. Their declared dimensions must match; Dhaga checks this before indexing or searching and reports both provider ids and dimensions on mismatch.
An EmbeddingProvider implements embedDocuments and embedQuery. A
VectorStore implements deterministic upsert, similarity search, existence
checks, and deletion. Owner pairs (ownerType, ownerId) are stable keys, so
upserts must be idempotent.
Register them with registerEmbeddingProvider and registerVectorStore, then
select them in code or with:
DHAGA_EMBEDDING_PROVIDER=my-embeddings
DHAGA_VECTOR_STORE=my-vectorsVectorWriteOptions.transaction is an optional store-specific handle. The
built-in pgvector implementation uses it to keep relational tombstones and
vector deletion atomic. External services cannot participate in a Postgres
transaction and should make deletion idempotent and retry-safe.
The built-ins remain local-huggingface (384 dimensions) and pgvector.
Setting DHAGA_VECTOR_STORE to an external store also skips pgvector extension
and table setup, so the relational database no longer needs that extension.
Relational database boundary
The application datastore is currently PostgreSQL-compatible, supporting embedded PGlite and hosted PostgreSQL through the same Drizzle repositories. It is intentionally not advertised as a generic database plugin: repositories, full-text indexes, authentication, RLS, and migrations rely on PostgreSQL semantics. Vector data can be moved to an external engine independently.
Provider checklist
- Keep vendor SDK imports inside the provider package.
- Validate structured model output with the supplied Zod schema.
- Return normalized usage even when the vendor omits token counts.
- Declare only capabilities that the implementation actually supports.
- Make vector upsert and deletion idempotent.
- Test disabled/unconfigured behavior and malformed provider responses.
- Run
npm run typecheck --prefix packages/coreand the provider registry tests.
Deploying
The three ways Dhaga deploys — Vercel, a single persistent server, and Docker — plus the Postgres role and pooling rules that matter in hosted mode.
Cost & scaling on AWS
A service-by-service migration map off Supabase + Vercel, a cost model at 1k / 10k / 100k users, and the scaling architecture — built around the one number that moves the bill: model inference.