Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,8 +459,18 @@ current model count. This is the observability/trust requirement made concrete.
its own at all, returns an empty list in that case rather than the
shared Ollama-tag catalog's invalid names.
- `BundledRegistry` — reads `catalog.json` (offline fallback).
- `RemoteRegistry` — optional fetch/refresh from a URL/JSON for community
updates without a package release.
- `RemoteRegistry` (`adapters/registry/remote.py`) — optional fetch of a
`catalog.json`-shaped document from `registry_url`, for community updates
without a package release. Built on `CachedCatalogRegistry`, but
cache-*first*: the disk cache (`<cache_dir>/remote_catalog_cache.json`,
1-hour TTL) answers on its own inside the TTL so a configured URL costs no
round-trip per CLI invocation; `refresh()` bypasses it, and once a live
fetch fails an expired cache is still preferred over none. The remote
entries are merged *over* the bundled catalog rather than replacing it —
a remote entry wins a name collision, but no bundled model is ever
dropped, so a remote URL can only add to discovery. Non-`http(s)` URLs are
rejected up front, the response body is size-capped, and a single
malformed entry is skipped rather than discarding the whole payload.
- `CompositeRegistry` (`adapters/registry/composite.py`) — merges an
ordered list of `RegistryPort` sources into one: `search`/`list_all`/
`by_category`/`recommend` union every source's results (earlier source
Expand All @@ -471,6 +481,11 @@ current model count. This is the observability/trust requirement made concrete.
- **Configuration:** `catalog_source` setting in `Settings` controls which
registry is used: `"ollama"` (dynamic only) and `"bundled"` (static only)
are explicit single-source opt-outs with no network beyond that one source.
`"remote"` selects `RemoteRegistry` alone (still merged over bundled, inside
the registry itself) and requires `registry_url`. Setting `registry_url`
under `"auto"` instead merges the remote catalog in ahead of every other
source, so entries published since the last release outrank the shipped
ones; an unusable URL degrades to a warning rather than breaking discovery.
`"auto"` (default) tries dynamic Ollama, falls back to bundled, **and**
merges in the active backend's own live catalog when it has one —
`ModelManager._resolve_backend_catalog` resolves it through
Expand Down
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,50 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this

## [Unreleased]

### Added

- `registry_url` is now wired into discovery. Setting it under
`catalog_source="auto"` merges a remote catalog ahead of every other source;
`catalog_source="remote"` selects it as the only source (and errors when no
`registry_url` is configured). The setting was previously parsed and
displayed but never read.
- `RemoteRegistry` caches its fetch at `<cache_dir>/remote_catalog_cache.json`
with a 1-hour TTL, served cache-first so a configured URL costs no network
round-trip per command, and falls back to an expired cache before giving up.
`refresh()` (and therefore `modeldock sources refresh`) bypasses the TTL.
- `catalog_entry_to_spec`/`load_bundled_catalog` (`adapters/registry/bundled.py`)
— the catalog.json entry coercion, shared instead of reached for privately.

### Changed

- `RemoteRegistry` now merges remote entries *over* the bundled catalog rather
than replacing it: a remote entry wins a name collision, but no bundled
model is dropped, so a remote URL can only add to discovery.
- `RemoteRegistry` is built on `CachedCatalogRegistry`, so `search`,
`recommend`, `by_category` and `list_all` answer from the merged index, and
`get`/`resolve` honour aliases and casing like every other source.
- `CompositeRegistry.describe()` deduplicates sources by name, so a catalog
merged inside more than one member is listed once by `modeldock sources`.

### Fixed

- `RemoteRegistry.search()`/`recommend()` delegated to the bundled fallback,
so a model that existed only in the remote catalog could never be found —
the exact case the remote catalog exists for.
- A successful remote fetch replaced the catalog instead of extending it,
dropping every bundled model from `list_all()`/`by_category()`.
- `RemoteRegistry.get()` matched names exactly, ignoring aliases and casing.
- A single malformed entry in a remote payload discarded the entire fetch.
- `RemoteRegistry` exposed only a private `_refresh`, so `modeldock sources
refresh` and `CompositeRegistry.refresh()` skipped it silently.
- `RemoteRegistry.describe()` reported the URL as its cache path, and reported
the bundled fallback's model count as the remote source's when the fetch had
failed.
- `RemoteRegistry` fetched on every construction with no cache, putting a
network round-trip in front of every CLI invocation.
- `RemoteRegistry` accepted any URL scheme (including `file://`) and buffered
the whole response body unbounded.

## [0.2.0] - 2026-08-28

Live GGUF catalogs, composite registry, third-party catalog plugins, and
Expand Down
13 changes: 13 additions & 0 deletions Development.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,19 @@ To add a model to the **bundled fallback**, edit `src/modeldock/data/catalog.jso
```

Optional `RemoteRegistry` can refresh the catalog from a URL without a release.
Point `registry_url` at a document in exactly the shape above (a `models` list
of these entries) and ModelDock merges it over the bundled catalog:

```toml
# ~/.config/modeldock/config.toml
registry_url = "https://example.com/modeldock-catalog.json"
```

Or `MODELDOCK_REGISTRY_URL=... modeldock search llama`. The fetch is cached at
`<cache_dir>/remote_catalog_cache.json` for an hour, so only the first command
in that window touches the network; `modeldock sources refresh` forces a
re-fetch, and `modeldock sources` shows what the remote URL contributed.
Setting `catalog_source = "remote"` uses it as the only source.

---

Expand Down
7 changes: 6 additions & 1 deletion QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,14 @@ for 24 hours. Set `catalog_source` in config or `MODELDOCK_CATALOG_SOURCE` env v

| Value | Behavior |
|-------|----------|
| `auto` | Try dynamic, fallback to bundled (default) |
| `auto` | Try dynamic, fallback to bundled; merges `registry_url` when set (default) |
| `ollama` | Dynamic only — requires internet |
| `bundled` | Static catalog.json only — fully offline |
| `remote` | `registry_url` only, merged over bundled — requires `registry_url` |

Set `registry_url` to a `catalog.json`-shaped URL to pick up models published
since the last release. Its entries are merged over the bundled catalog (never
replacing it) and cached for an hour; `modeldock sources refresh` re-fetches.

---

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,14 @@ for 24 hours. Set `catalog_source` in config or `MODELDOCK_CATALOG_SOURCE` env v

| Value | Behavior |
|-------|----------|
| `auto` | Try dynamic, fallback to bundled (default) |
| `auto` | Try dynamic, fallback to bundled; merges `registry_url` when set (default) |
| `ollama` | Dynamic only — requires internet |
| `bundled` | Static catalog.json only — fully offline |
| `remote` | `registry_url` only, merged over bundled — requires `registry_url` |

Set `registry_url` to a `catalog.json`-shaped URL to pick up models published
since the last release. Its entries are merged over the bundled catalog (never
replacing it) and cached for an hour; `modeldock sources refresh` re-fetches.

See [Architecture.md](Architecture.md) for the full design contract.

Expand Down
32 changes: 26 additions & 6 deletions src/modeldock/adapters/registry/bundled.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ def _load_catalog() -> List[Dict[str, Any]]:
return cast(List[Dict[str, Any]], data.get("models", []))


def load_bundled_catalog() -> List[Dict[str, Any]]:
"""Return the raw entries of the bundled catalog.json.

Public counterpart to ``_load_catalog`` so other registries can read the
shipped catalog without importing a private name.
"""
return _load_catalog()


def catalog_entry_to_spec(raw: Dict[str, Any]) -> ModelSpec:
"""Coerce one raw catalog.json entry into a validated ``ModelSpec``.

Shared by every registry that consumes catalog.json-shaped entries — the
bundled catalog and any remote catalog served in the same format — so the
enum coercion rules live in exactly one place instead of being reached for
across class boundaries.
"""
raw = dict(raw)
raw["category"] = Category.from_value(raw["category"])
raw["capabilities"] = [Capability.from_value(c) for c in raw.get("capabilities", [])]
raw["backend_hints"] = [RuntimeBackend.from_value(b) for b in raw.get("backend_hints", [])]
return ModelSpec.model_validate(raw)


class BundledRegistry:
"""Registry backed by the bundled catalog.json."""

Expand All @@ -62,11 +86,7 @@ def _index(self) -> None:

@staticmethod
def _to_spec(raw: Dict[str, Any]) -> ModelSpec:
raw = dict(raw)
raw["category"] = Category.from_value(raw["category"])
raw["capabilities"] = [Capability.from_value(c) for c in raw.get("capabilities", [])]
raw["backend_hints"] = [RuntimeBackend.from_value(b) for b in raw.get("backend_hints", [])]
return ModelSpec.model_validate(raw)
return catalog_entry_to_spec(raw)

# --- RegistryPort -----------------------------------------------------

Expand Down Expand Up @@ -125,4 +145,4 @@ def list_all(self) -> List[ModelSpec]:
return list(self._specs.values())


__all__ = ["BundledRegistry"]
__all__ = ["BundledRegistry", "catalog_entry_to_spec", "load_bundled_catalog"]
19 changes: 15 additions & 4 deletions src/modeldock/adapters/registry/composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from __future__ import annotations

from typing import Dict, List, Optional, Sequence
from typing import Dict, List, Optional, Sequence, Set

from modeldock.common.errors import ModelNotFoundError
from modeldock.domain.model import Category, ModelRef, ModelSpec
Expand Down Expand Up @@ -78,21 +78,32 @@ def describe(self) -> List[SourceInfo]:
Each source that can describe itself contributes its own
``SourceInfo``; a source that predates the ``describe`` contract still
appears, as a minimal custom entry, so the enumeration is complete.

Descriptors are deduplicated by source name, keeping the first: a
member may itself merge a shared source underneath it (``RemoteRegistry``
overlays the bundled catalog), and listing that source once per member
would misrepresent one catalog as several.
"""
infos: List[SourceInfo] = []
seen: Set[str] = set()
for source in self._sources:
describe = getattr(source, "describe", None)
if callable(describe):
infos.extend(describe())
candidates = list(describe())
else:
infos.append(
candidates = [
SourceInfo(
name=type(source).__name__,
trust=SourceTrust.CUSTOM,
live=True,
model_count=len(source.list_all()),
)
)
]
for info in candidates:
if info.name in seen:
continue
seen.add(info.name)
infos.append(info)
return infos

def refresh(self) -> int:
Expand Down
Loading
Loading