Current section

Files

Jump to
llm_db lib llm_db loader.ex
Raw

lib/llm_db/loader.ex

defmodule LLMDB.Loader do
@moduledoc """
Handles loading and merging of packaged snapshots with runtime customization.
Phase 2 of LLMDB: Load the packaged snapshot, apply custom overlays,
compile filters, and build indexes for runtime queries.
This module encapsulates all snapshot loading logic, keeping the main
LLMDB module focused on the query API.
"""
alias LLMDB.{Engine, Merge, Model, Packaged, Pricing, Provider, Runtime, Snapshot, Validate}
alias LLMDB.Snapshot.ReleaseStore
require Logger
@doc """
Loads the packaged snapshot and applies runtime configuration.
This is the main entry point for Phase 2 (runtime) loading. It:
1. Loads the packaged snapshot
2. Normalizes providers/models from v1 or v2 format
3. Merges custom providers/models overlay
4. Compiles and applies filters
5. Builds indexes for O(1) queries
6. Returns snapshot ready for Store
## Parameters
- `opts` - Keyword list passed to Runtime.compile/1
## Returns
- `{:ok, snapshot}` - Successfully loaded and prepared snapshot
- `{:error, :no_snapshot}` - No packaged snapshot available
- `{:error, term}` - Other errors
## Examples
{:ok, snapshot} = Loader.load()
{:ok, snapshot} = Loader.load(
allow: [:openai],
custom: %{
local: [
models: %{"llama-3" => %{capabilities: %{chat: true}}}
]
}
)
"""
@spec load(keyword()) :: {:ok, map()} | {:error, term()}
def load(opts \\ []) do
with {:ok, {providers, models, generated_at, source_snapshot_id}} <- load_packaged(opts),
runtime <- Runtime.compile(opts ++ [provider_ids: Enum.map(providers, & &1.id)]),
:ok <- warn_unknown_providers(runtime.unknown, providers),
{providers2, models2} <- merge_custom({providers, models}, runtime.custom),
models3 <- Pricing.apply_cost_components(models2),
models4 <- Pricing.apply_provider_defaults(providers2, models3),
filtered_models <- Engine.apply_filters(models4, runtime.filters),
:ok <- validate_not_empty(filtered_models, runtime),
snapshot <-
build_snapshot(
providers2,
filtered_models,
models4,
runtime,
generated_at,
source_snapshot_id
) do
{:ok, snapshot}
end
end
@doc """
Builds an empty snapshot with no providers or models.
Used as a fallback when no packaged snapshot is available.
## Examples
{:ok, snapshot} = Loader.load_empty()
"""
@spec load_empty(keyword()) :: {:ok, map()}
def load_empty(opts \\ []) do
runtime = Runtime.compile(opts)
snapshot = %{
providers_by_id: %{},
models_by_key: %{},
aliases_by_key: %{},
providers: [],
models: %{},
base_models: [],
filters: runtime.filters,
prefer: runtime.prefer,
meta: %{
epoch: nil,
source_generated_at: nil,
source_snapshot_id: nil,
loaded_at: DateTime.utc_now() |> DateTime.to_iso8601(),
digest: compute_digest([], [], runtime)
}
}
{:ok, snapshot}
end
@doc """
Computes a digest for a snapshot configuration.
Used to detect if a reload would result in the same snapshot,
enabling idempotent load operations.
## Parameters
- `providers` - List of provider maps
- `base_models` - List of all models before filtering
- `runtime` - Runtime configuration map
## Returns
Integer digest (phash2 hash)
"""
@spec compute_digest(list(), list(), map()) :: integer()
def compute_digest(providers, base_models, runtime) do
# Stable hash of configuration that affects the final snapshot
:erlang.phash2({
# Provider IDs (order matters for determinism)
Enum.map(providers, & &1.id),
# Model keys (provider + id)
Enum.map(base_models, fn m -> {m.provider, m.id} end),
# Runtime config that affects filtering
runtime.raw_allow,
runtime.raw_deny,
runtime.prefer,
# Custom overlay
custom_digest(runtime.custom)
})
end
# Private helpers
defp load_packaged(opts) do
case load_snapshot_document(snapshot_source(opts)) do
nil ->
{:error, :no_snapshot}
%{version: 2, providers: nested_providers, generated_at: generated_at} = snapshot ->
# V2 snapshot with nested providers
{providers, models} = flatten_nested_providers(nested_providers)
{:ok,
{deserialize_json_atoms(providers, :provider), deserialize_json_atoms(models, :model),
generated_at, snapshot_snapshot_id(snapshot)}}
%{"version" => 2, "providers" => nested_providers, "generated_at" => generated_at} =
snapshot ->
{providers, models} = flatten_nested_providers(nested_providers)
{:ok,
{deserialize_json_atoms(providers, :provider), deserialize_json_atoms(models, :model),
generated_at, snapshot_snapshot_id(snapshot)}}
%{providers: providers, models: models, generated_at: generated_at} = snapshot
when is_list(providers) and is_list(models) ->
# V1 snapshot with generated_at
{:ok,
{deserialize_json_atoms(providers, :provider), deserialize_json_atoms(models, :model),
generated_at, snapshot_snapshot_id(snapshot)}}
%{"providers" => providers, "models" => models, "generated_at" => generated_at} = snapshot
when is_list(providers) and is_list(models) ->
{:ok,
{deserialize_json_atoms(providers, :provider), deserialize_json_atoms(models, :model),
generated_at, snapshot_snapshot_id(snapshot)}}
%{providers: providers, models: models} = snapshot
when is_list(providers) and is_list(models) ->
# V1 snapshot without generated_at
{:ok,
{deserialize_json_atoms(providers, :provider), deserialize_json_atoms(models, :model),
nil, snapshot_snapshot_id(snapshot)}}
%{"providers" => providers, "models" => models} = snapshot
when is_list(providers) and is_list(models) ->
{:ok,
{deserialize_json_atoms(providers, :provider), deserialize_json_atoms(models, :model),
nil, snapshot_snapshot_id(snapshot)}}
_ ->
{:error, :invalid_snapshot_format}
end
end
defp flatten_nested_providers(nested_providers) when is_map(nested_providers) do
{providers, all_models} =
Enum.reduce(nested_providers, {[], []}, fn {_provider_id, provider_data},
{acc_providers, acc_models} ->
# Extract provider without models key
provider = map_delete(provider_data, :models)
# Get provider ID as string for models
provider_id_str =
case get_value(provider_data, :id) do
a when is_atom(a) -> Atom.to_string(a)
s when is_binary(s) -> s
end
# Extract models and ensure they have provider field
models =
case get_value(provider_data, :models) do
models when is_map(models) ->
Enum.map(models, fn {_model_id, model_data} ->
map_put_new(model_data, :provider, provider_id_str)
end)
_ ->
[]
end
{[provider | acc_providers], models ++ acc_models}
end)
{Enum.reverse(providers), Enum.reverse(all_models)}
end
defp deserialize_json_atoms(items, :provider) do
Enum.map(items, fn provider ->
provider_map = normalize_provider_item(provider)
# Validate and create Provider struct
case provider_map do
%Provider{} = normalized -> normalized
_ -> Provider.new!(provider_map)
end
end)
end
defp deserialize_json_atoms(items, :model) do
Enum.map(items, fn model ->
model_map = normalize_model_item(model)
# Validate and create Model struct
case model_map do
%Model{} = normalized -> normalized
_ -> Model.new!(model_map)
end
end)
end
defp deserialize_modality_list(list) when is_list(list) do
Enum.map(list, fn
s when is_binary(s) -> safe_existing_atom(s)
a when is_atom(a) -> a
end)
end
defp merge_custom({providers, models}, %{providers: [], models: []}) do
# No custom overlay
{providers, models}
end
defp merge_custom({providers, models}, custom) do
custom_providers = validate_custom_overlays!(custom.providers, :provider)
custom_models = validate_custom_overlays!(custom.models, :model)
merged_providers =
providers
|> Merge.merge_providers(custom_providers)
|> revalidate_merged!(&Validate.validate_provider/1, "custom provider")
merged_models =
models
|> Merge.merge_models(custom_models, %{})
|> revalidate_merged!(&Validate.validate_model/1, "custom model")
{merged_providers, merged_models}
end
defp validate_custom_overlays!(items, :provider) when is_list(items) do
Enum.map(items, fn provider ->
provider
|> normalize_provider_item()
|> validate_custom_overlay!(&Validate.validate_provider_overlay/1, "custom provider")
end)
end
defp validate_custom_overlays!(items, :model) when is_list(items) do
Enum.map(items, fn model ->
model
|> normalize_model_item()
|> validate_custom_overlay!(&Validate.validate_model_overlay/1, "custom model")
end)
end
defp validate_custom_overlay!(item, validator, label) do
case validator.(item) do
{:ok, overlay} ->
overlay
{:error, reason} ->
raise ArgumentError, "Invalid #{label}: #{inspect(reason)}"
end
end
defp revalidate_merged!(items, validator, label) when is_list(items) do
Enum.map(items, fn item ->
case validator.(item) do
{:ok, validated} ->
validated
{:error, reason} ->
raise ArgumentError, "Invalid #{label} after merge: #{inspect(reason)}"
end
end)
end
defp warn_unknown_providers([], _providers), do: :ok
defp warn_unknown_providers(unknown_providers, providers) do
provider_ids_set = MapSet.new(providers, & &1.id)
Logger.warning(
"llm_db: unknown provider(s) in filter: #{inspect(unknown_providers)}. " <>
"Known providers: #{inspect(MapSet.to_list(provider_ids_set))}. " <>
"Check spelling or remove unknown providers from configuration."
)
:ok
end
defp validate_not_empty(filtered_models, runtime) do
if runtime.filters.allow != :all and filtered_models == [] do
{:error,
"llm_db: filters eliminated all models. Check :llm_db filter configuration. " <>
"allow: #{summarize_filter(runtime.raw_allow)}, deny: #{summarize_filter(runtime.raw_deny)}. " <>
"Use allow: :all to widen filters or remove deny patterns."}
else
:ok
end
end
defp build_snapshot(
providers,
filtered_models,
base_models,
runtime,
generated_at,
source_snapshot_id
) do
# Apply provider aliases AFTER all sources are loaded
providers_with_aliases = apply_provider_aliases(providers)
%{
providers_by_id: index_providers(providers_with_aliases),
models_by_key: index_models(filtered_models),
aliases_by_key: index_aliases(filtered_models),
providers: providers_with_aliases,
models: Enum.group_by(filtered_models, & &1.provider),
base_models: base_models,
filters: runtime.filters,
prefer: runtime.prefer,
meta: %{
epoch: nil,
source_generated_at: generated_at,
source_snapshot_id: source_snapshot_id,
loaded_at: DateTime.utc_now() |> DateTime.to_iso8601(),
digest: compute_digest(providers, base_models, runtime)
}
}
end
# Apply provider aliases post-load (after all sources merged)
# This handles cases where a single implementation (e.g., GoogleVertex)
# should handle models from multiple LLMDB providers
defp apply_provider_aliases(providers) do
# Hardcoded provider alias mappings
# Key: aliased provider ID, Value: primary provider ID
alias_map = %{
google_vertex_anthropic: :google_vertex
}
Enum.map(providers, fn provider ->
case Map.get(alias_map, provider.id) do
nil -> provider
primary_id -> Map.put(provider, :alias_of, primary_id)
end
end)
end
defp index_providers(providers), do: Map.new(providers, &{&1.id, &1})
defp index_models(models), do: Map.new(models, &{{&1.provider, &1.id}, &1})
defp index_aliases(models) do
models
|> Enum.flat_map(fn model ->
provider = model.provider
canonical_id = model.id
aliases = Map.get(model, :aliases, [])
Enum.map(aliases, fn alias_name ->
{{provider, alias_name}, canonical_id}
end)
end)
|> Map.new()
end
defp custom_digest(%{providers: [], models: []}), do: nil
defp custom_digest(%{providers: providers, models: models}) do
:erlang.phash2({
Enum.map(providers, & &1.id),
Enum.map(models, fn m -> {m.provider, m.id} end)
})
end
defp summarize_filter(:all), do: ":all"
defp summarize_filter(filter) when is_map(filter) and map_size(filter) == 0 do
"%{}"
end
defp summarize_filter(filter) when is_map(filter) do
keys = Map.keys(filter) |> Enum.take(5)
if map_size(filter) > 5 do
"#{inspect(keys)} ... (#{map_size(filter)} providers total)"
else
inspect(filter)
end
end
defp snapshot_source(opts) do
base = LLMDB.Config.get()
Keyword.get(opts, :snapshot_source, base.snapshot_source)
end
defp load_snapshot_document(:packaged), do: Packaged.snapshot()
defp load_snapshot_document({:file, path}) when is_binary(path) do
case Snapshot.read(path) do
{:ok, snapshot} -> snapshot
{:error, _reason} -> nil
end
end
defp load_snapshot_document({:github_releases, store_opts}) do
{ref, overrides} = release_ref_and_overrides(store_opts)
case ReleaseStore.fetch_snapshot(ref, overrides) do
{:ok, %{snapshot: snapshot}} -> snapshot
{:error, _reason} -> nil
end
end
defp load_snapshot_document(other) when is_binary(other),
do: load_snapshot_document({:file, other})
defp load_snapshot_document(_), do: Packaged.snapshot()
defp release_ref_and_overrides(store_opts) do
normalized =
cond do
is_map(store_opts) -> store_opts
Keyword.keyword?(store_opts) -> Enum.into(store_opts, %{})
true -> %{}
end
ref =
Map.get(normalized, :ref) ||
Map.get(normalized, "ref") ||
:latest
overrides =
normalized
|> Map.delete(:ref)
|> Map.delete("ref")
{ref, overrides}
end
defp snapshot_snapshot_id(snapshot) when is_map(snapshot) do
snapshot["snapshot_id"] || snapshot[:snapshot_id]
end
defp provider_atom(provider) when is_binary(provider) do
safe_existing_atom(provider, true)
end
defp normalize_provider_item(provider) do
normalized_id =
case get_value(provider, :id) do
id when is_atom(id) -> id
id when is_binary(id) -> provider_atom(id)
end
provider
|> deep_atomize_keys()
|> Map.put(:id, normalized_id)
end
defp normalize_model_item(model) do
normalized_provider =
case get_value(model, :provider) do
provider when is_atom(provider) -> provider
provider when is_binary(provider) -> provider_atom(provider)
end
model
|> normalize_model_modalities()
|> put_value(:provider, normalized_provider)
|> deep_atomize_keys()
end
defp normalize_model_modalities(model) do
case get_value(model, :modalities) do
modalities when is_map(modalities) ->
normalized =
modalities
|> maybe_normalize_modality(:input)
|> maybe_normalize_modality(:output)
put_value(model, :modalities, normalized)
_other ->
model
end
end
defp maybe_normalize_modality(modalities, key) do
case get_value(modalities, key) do
list when is_list(list) -> put_value(modalities, key, deserialize_modality_list(list))
_other -> modalities
end
end
defp safe_existing_atom(value, allow_create \\ false)
defp safe_existing_atom(value, allow_create) when is_binary(value) do
String.to_existing_atom(value)
rescue
ArgumentError ->
_ = allow_create
String.to_atom(value)
end
defp get_value(map, key) when is_map(map) do
Map.get(map, key, Map.get(map, Atom.to_string(key)))
end
defp put_value(%_{} = struct, key, value), do: Map.put(struct, key, value)
defp put_value(map, key, value) when is_map(map) do
cond do
Map.has_key?(map, key) -> Map.put(map, key, value)
Map.has_key?(map, Atom.to_string(key)) -> Map.put(map, Atom.to_string(key), value)
true -> Map.put(map, key, value)
end
end
defp map_put_new(%_{} = struct, key, value), do: Map.put_new(struct, key, value)
defp map_put_new(map, key, value) when is_map(map) do
cond do
Map.has_key?(map, key) -> map
Map.has_key?(map, Atom.to_string(key)) -> map
true -> Map.put(map, key, value)
end
end
defp map_delete(%_{} = struct, key), do: Map.delete(struct, key)
defp map_delete(map, key) when is_map(map) do
map
|> Map.delete(key)
|> Map.delete(Atom.to_string(key))
end
defp deep_atomize_keys(%_{} = struct), do: struct
defp deep_atomize_keys(map) when is_map(map) do
Map.new(map, fn {key, value} ->
normalized_key =
case key do
key when is_atom(key) -> key
key when is_binary(key) -> safe_existing_atom(key, true)
end
{normalized_key, deep_atomize_keys(value)}
end)
end
defp deep_atomize_keys(list) when is_list(list), do: Enum.map(list, &deep_atomize_keys/1)
defp deep_atomize_keys(value), do: value
end