Skip to content

Configuration

Typed configuration, frozen and fail-closed: unknown keys raise at construction time and there is no lenient mode.

API keys are never configuration values. EmbeddingConfig carries the name of the environment variable to read at call time, so a key cannot end up serialized into a config dump, a log line, or an index manifest (SPEC.md §7).

base_url decides where your text goes — not provider

EmbeddingConfig.base_url is unconstrained operator input. Pointing the ollama provider at a remote host sends full document text over the network while the config still reads as local. See The LLM boundary.

groundkit.config

Typed configuration — frozen Pydantic models, fail closed on unknown keys.

Adapted from ARP's agentic_v2/rag/config.py per ADR-0001: the frozen + extra="forbid" + real-invariant-validator pattern is kept; the enums and defaults are reshaped for groundkit's local-first design (Ollama default, configurable endpoint, hybrid retrieval parameters). Unknown keys raise at construction time — there is no lenient mode.

API keys are never configuration values: config carries the name of the environment variable to read at call time (SPEC.md §7).

ChatConfig

Bases: BaseModel

Configuration for the chat/completion provider (Phase 5, ADR-0017).

The exact peer of :class:EmbeddingConfig: local-first, Ollama by default, cloud opt-in. There is no "scripted" provider literal on purpose — :class:~groundkit.providers.llm.ScriptedChatProvider is a test double constructed directly with its script; a config claiming to describe one would name a runtime artifact no config can reconstruct.

Attributes:

Name Type Description
provider Literal['ollama', 'openai_compatible']

"ollama" (default, local) or "openai_compatible" (opt-in cloud; its egress is redacted — ADR-0017).

model_name str

Model identifier for the provider.

base_url str

Provider endpoint. Defaults to the local Ollama endpoint.

api_key_env str

Name of the environment variable holding the API key for openai_compatible. Read at call time, never stored or logged.

timeout_seconds float

Per-request timeout.

ChunkingConfig

Bases: BaseModel

Configuration for document chunking.

Sizes are in characters (ARP's docs said tokens while the code split characters; groundkit documents what the code does).

Attributes:

Name Type Description
chunk_size int

Target chunk size in characters.

chunk_overlap int

Overlap between consecutive chunks, in characters.

separators list[str]

Split separators, tried in order (recursive strategy).

EmbeddingConfig

Bases: BaseModel

Configuration for embedding generation. Local-first: Ollama by default.

Attributes:

Name Type Description
provider Literal['ollama', 'openai_compatible', 'inmemory']

"ollama" (default, local), "openai_compatible" (opt-in cloud), or "inmemory" (deterministic test double — never a production choice; it has no semantic signal).

model_name str

Model identifier for the provider.

dimensions int

Expected embedding vector width; responses that do not match raise EmbeddingError (fail loud, never corrupt an index).

base_url str

Provider endpoint. Defaults to the local Ollama endpoint.

api_key_env str

Name of the environment variable holding the API key for openai_compatible. Read at call time, never stored or logged.

batch_size int

Max texts per embedding request.

max_concurrent int

Concurrency limit for embedding requests.

timeout_seconds float

Per-request timeout.

GroundkitConfig

Bases: BaseModel

Top-level configuration composing all component configs.

Attributes:

Name Type Description
chunking ChunkingConfig

Document chunking settings.

embedding EmbeddingConfig

Embedding provider settings.

index IndexConfig

Persisted index settings.

retrieval RetrievalConfig

Retrieval settings.

IndexConfig

Bases: BaseModel

Configuration for the persisted index.

Attributes:

Name Type Description
index_dir str

Directory holding the collection's persisted state (SQLite metadata store; the LanceDB table backing :class:~groundkit.index.dense.LanceDBVectorStore, when configured).

collection str

Collection name.

RetrievalConfig

Bases: BaseModel

Configuration for retrieval.

Attributes:

Name Type Description
top_k int

Default number of results to return.

bm25_k1 float

BM25 term-frequency saturation parameter.

bm25_b float

BM25 length-normalization parameter.

score_threshold float | None

Minimum score filter. None disables filtering — explicit, instead of ARP's silent 0.0-means-off sentinel.

rrf_k int

Reciprocal-rank-fusion constant (used from Phase 3).

resolve_chat_config

resolve_chat_config(
    *,
    provider: Literal["ollama", "openai_compatible"] | None,
    model_name: str | None,
    base_url: str | None,
    api_key_env: str | None,
    timeout_seconds: float | None = None,
) -> ChatConfig

Build a :class:ChatConfig, defaulting any None field.

The same boundary-translation shape as :func:resolve_embedding_config, for the same reasons — see its docstring for the typing rationale and why the ValidationError is translated exactly here.

Parameters:

Name Type Description Default
timeout_seconds float | None

Per-request timeout override, or None to keep :data:DEFAULT_CHAT_TIMEOUT_SECONDS. Keyword-only with a default so every existing positional-by-keyword call site (there is no positional form) keeps working unchanged.

None

Raises:

Type Description
ConfigurationError

A supplied value violates a :class:ChatConfig invariant.

Source code in src/groundkit/config.py
def resolve_chat_config(
    *,
    provider: Literal["ollama", "openai_compatible"] | None,
    model_name: str | None,
    base_url: str | None,
    api_key_env: str | None,
    timeout_seconds: float | None = None,
) -> ChatConfig:
    """Build a :class:`ChatConfig`, defaulting any ``None`` field.

    The same boundary-translation shape as :func:`resolve_embedding_config`,
    for the same reasons — see its docstring for the typing rationale and why
    the ``ValidationError`` is translated exactly here.

    Args:
        timeout_seconds: Per-request timeout override, or ``None`` to keep
            :data:`DEFAULT_CHAT_TIMEOUT_SECONDS`. Keyword-only with a default
            so every existing positional-by-keyword call site (there is no
            positional form) keeps working unchanged.

    Raises:
        ConfigurationError: A supplied value violates a :class:`ChatConfig`
            invariant.
    """
    defaults = ChatConfig()
    try:
        return ChatConfig(
            provider=provider if provider is not None else defaults.provider,
            model_name=model_name if model_name is not None else defaults.model_name,
            base_url=base_url if base_url is not None else defaults.base_url,
            api_key_env=api_key_env if api_key_env is not None else defaults.api_key_env,
            timeout_seconds=(
                timeout_seconds if timeout_seconds is not None else defaults.timeout_seconds
            ),
        )
    except ValidationError as exc:
        details = "; ".join(
            f"{'.'.join(str(part) for part in error['loc'])}: {error['msg']}"
            for error in exc.errors()
        )
        raise ConfigurationError(f"invalid chat configuration ({details})") from exc

resolve_embedding_config

resolve_embedding_config(
    *,
    provider: Literal[
        "ollama", "openai_compatible", "inmemory"
    ]
    | None,
    model_name: str | None,
    dimensions: int | None,
    base_url: str | None,
) -> EmbeddingConfig

Build an :class:EmbeddingConfig, defaulting any None field.

Each parameter is typed as the corresponding :class:EmbeddingConfig field's own type — provider is the same Literal["ollama", "openai_compatible", "inmemory"] | None that :attr:EmbeddingConfig.provider uses, not str | None — rather than a heterogeneous dict[str, ...] splat, which cannot type-check cleanly against :class:EmbeddingConfig's typed fields under mypy --strict.

A caller that already holds typed values gets full checking at this boundary. A caller holding an untyped value can still pass it straight through with no cast: argparse.Namespace attributes are typed Any, and Any is assignable to any parameter type, so the permissiveness that makes passthrough type-check lives at the call site, not here. groundkit.cli._resolve_embedding_config is exactly that call site — it unpacks a parsed args.embed_* namespace and passes the attributes straight into these keyword parameters. Pydantic still validates the provider literal at construction below (argparse's own choices already constrains it on that call path, so this is defense in depth, not the only check).

Defaults come from a fresh :class:EmbeddingConfig — one source of truth, never a second copy of its field defaults.

Pydantic's own field invariants (dimensions must be > 0, and any other bound :class:EmbeddingConfig grows later) are enforced here and nowhere else on this path, so the ValidationError they raise is translated into a :class:~groundkit.errors.ConfigurationError. Untranslated it is not a :class:~groundkit.errors.GroundkitError, so cli.main's handler never sees it and grk ingest --dense --embed-dimensions 0 exits on a pydantic traceback instead of the one-line error: message every other bad flag produces. Translating at this single construction site rather than re-checking each bound at every caller keeps :class:EmbeddingConfig the only place a bound is stated.

Raises:

Type Description
ConfigurationError

A supplied value violates an :class:EmbeddingConfig invariant.

Source code in src/groundkit/config.py
def resolve_embedding_config(
    *,
    provider: Literal["ollama", "openai_compatible", "inmemory"] | None,
    model_name: str | None,
    dimensions: int | None,
    base_url: str | None,
) -> EmbeddingConfig:
    """Build an :class:`EmbeddingConfig`, defaulting any ``None`` field.

    Each parameter is typed as the corresponding :class:`EmbeddingConfig`
    field's own type — ``provider`` is the same ``Literal["ollama",
    "openai_compatible", "inmemory"] | None`` that
    :attr:`EmbeddingConfig.provider` uses, not ``str | None`` — rather than
    a heterogeneous ``dict[str, ...]`` splat, which cannot type-check
    cleanly against :class:`EmbeddingConfig`'s typed fields under ``mypy
    --strict``.

    A caller that already holds typed values gets full checking at this
    boundary. A caller holding an *untyped* value can still pass it straight
    through with no ``cast``: ``argparse.Namespace`` attributes are typed
    ``Any``, and ``Any`` is assignable to any parameter type, so the
    permissiveness that makes passthrough type-check lives at the *call
    site*, not here. ``groundkit.cli._resolve_embedding_config`` is exactly
    that call site — it unpacks a parsed ``args.embed_*`` namespace and
    passes the attributes straight into these keyword parameters. Pydantic
    still validates the ``provider`` literal at construction below (argparse's
    own ``choices`` already constrains it on that call path, so this is
    defense in depth, not the only check).

    Defaults come from a fresh :class:`EmbeddingConfig` — one source of
    truth, never a second copy of its field defaults.

    Pydantic's own field invariants (``dimensions`` must be ``> 0``, and any
    other bound :class:`EmbeddingConfig` grows later) are enforced here and
    nowhere else on this path, so the ``ValidationError`` they raise is
    translated into a :class:`~groundkit.errors.ConfigurationError`.
    Untranslated it is not a :class:`~groundkit.errors.GroundkitError`, so
    ``cli.main``'s handler never sees it and ``grk ingest --dense
    --embed-dimensions 0`` exits on a pydantic traceback instead of the
    one-line ``error:`` message every other bad flag produces. Translating
    at this single construction site rather than re-checking each bound at
    every caller keeps :class:`EmbeddingConfig` the only place a bound is
    stated.

    Raises:
        ConfigurationError: A supplied value violates an
            :class:`EmbeddingConfig` invariant.
    """
    defaults = EmbeddingConfig()
    try:
        return EmbeddingConfig(
            provider=provider if provider is not None else defaults.provider,
            model_name=model_name if model_name is not None else defaults.model_name,
            dimensions=dimensions if dimensions is not None else defaults.dimensions,
            base_url=base_url if base_url is not None else defaults.base_url,
        )
    except ValidationError as exc:
        details = "; ".join(
            f"{'.'.join(str(part) for part in error['loc'])}: {error['msg']}"
            for error in exc.errors()
        )
        raise ConfigurationError(f"invalid embedding configuration ({details})") from exc