dhaga.docs
Self-hosting

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 DhagaAdd 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:

idImplementationConfigured when
anthropicAnthropicSearchClient — Anthropic's own server-side web_search tool, run on the extract-tier modelANTHROPIC_API_KEY is set
firecrawlFirecrawlSearchClient — Firecrawl's search APIFIRECRAWL_API_KEY is set

Which one runs, most specific first: an explicit selectSearchProvider(id)SEARCH_PROVIDERFIRECRAWL_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-vectors

VectorWriteOptions.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/core and the provider registry tests.

On this page