Packages
llm_db
2026.4.0
2026.7.3
2026.7.2
2026.7.1
2026.7.0
2026.6.4
2026.6.3
2026.6.2
2026.6.1
2026.6.0
2026.5.2
2026.5.1
2026.5.0
2026.4.8
2026.4.7
2026.4.6
2026.4.5
2026.4.4
2026.4.3
2026.4.2
2026.4.1
2026.4.0
2026.3.3
2026.3.2
2026.3.1
2026.3.0
2026.2.9
2026.2.8
2026.2.7
2026.2.6
2026.2.5
2026.2.4
2026.2.3
2026.2.2
2026.2.1
2026.2.0
2026.1.5
2026.1.4
2026.1.3
2026.1.2
2026.1.1
2026.1.0
2025.12.4
2025.12.3
2025.12.2
2025.12.1
2025.11.18-preview
2025.11.14-preview
2025.11.7-preview
LLM model metadata catalog with fast, capability-aware lookups.
Current section
Files
Jump to
Current section
Files
lib/llm_db/spec.ex
defmodule LLMDB.Spec do
@moduledoc """
Canonical "provider:model" spec parsing and resolution.
This module provides functions to parse and resolve model specifications in various formats,
including "provider:model" strings, "model@provider" strings (filename-safe), tuples,
and bare model IDs with provider scope.
## String Formats
Two string formats are supported:
- `"provider:model"` - Traditional colon separator (default)
- `"model@provider"` - Email-like format, filesystem-safe for filenames
Both formats parse to the same internal representation and can be used interchangeably.
The @ format is recommended when model specs are used in filenames, CI artifact names,
or other filesystem contexts.
## Amazon Bedrock Inference Profiles
For Amazon Bedrock models, inference profile IDs with region prefixes (us., eu., ap., apac., ca.,
au., jp., us-gov., global.) are supported. The region prefix is stripped for catalog lookup but
preserved in the returned model ID. For example:
iex> LLMDB.Spec.resolve("amazon_bedrock:us.anthropic.claude-opus-4-1-20250805-v1:0")
{:ok, {:amazon_bedrock, "us.anthropic.claude-opus-4-1-20250805-v1:0", %LLMDB.Model{}}}
The lookup uses "anthropic.claude-opus-4-1-20250805-v1:0" to find metadata, but the returned
model ID retains the "us." prefix for API routing purposes.
"""
alias LLMDB.{Normalize, Store}
alias LLMDB.Model
# Valid Bedrock inference profile region prefixes.
# See: https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
@bedrock_prefixes ~w(us. eu. ap. apac. ca. au. jp. us-gov. global.)
@doc """
Parses and validates a provider identifier.
Accepts atom or binary input, normalizes to atom, and verifies the provider
exists in the current catalog.
## Parameters
- `input` - Provider identifier as atom or binary
## Returns
- `{:ok, atom}` - Normalized provider atom if valid and exists in catalog
- `{:error, :unknown_provider}` - Provider not found in catalog
- `{:error, :bad_provider}` - Invalid provider format
## Examples
iex> LLMDB.Spec.parse_provider(:openai)
{:ok, :openai}
iex> LLMDB.Spec.parse_provider("google-vertex")
{:ok, :google_vertex}
iex> LLMDB.Spec.parse_provider("nonexistent")
{:error, :unknown_provider}
"""
@spec parse_provider(atom() | binary()) ::
{:ok, atom()} | {:error, :unknown_provider | :bad_provider}
def parse_provider(input) do
with {:ok, provider_atom} <- Normalize.normalize_provider_id(input),
{:ok, _} <- verify_provider_exists(provider_atom) do
{:ok, provider_atom}
else
{:error, :bad_provider} -> {:error, :bad_provider}
{:error, :unknown_provider} -> {:error, :unknown_provider}
end
end
@doc """
Parses a model specification string in either "provider:model" or "model@provider" format.
Automatically detects the format based on separators present. Validates the provider
exists in the catalog and checks for reserved characters in segments.
## Parameters
- `spec` - String in "provider:model" or "model@provider" format, or {provider, model_id} tuple
- `opts` - Keyword list with optional `:format` to explicitly specify format
## Options
- `:format` - Explicitly specify the format as `:colon` or `:at`. Required when both separators present.
## Returns
- `{:ok, {provider_atom, model_id}}` - Parsed and normalized spec
- `{:error, :invalid_format}` - No valid separator found
- `{:error, :ambiguous_format}` - Both separators present without explicit format
- `{:error, :unknown_provider}` - Provider not found in catalog
- `{:error, :bad_provider}` - Invalid provider format
- `{:error, :invalid_chars}` - Reserved characters in provider or model segments
- `{:error, :empty_segment}` - Provider or model segment is empty
## Examples
iex> LLMDB.Spec.parse_spec("openai:gpt-4")
{:ok, {:openai, "gpt-4"}}
iex> LLMDB.Spec.parse_spec("gpt-4@openai")
{:ok, {:openai, "gpt-4"}}
iex> LLMDB.Spec.parse_spec("google-vertex:gemini-pro")
{:ok, {:google_vertex, "gemini-pro"}}
iex> LLMDB.Spec.parse_spec("provider:model@ambiguous", format: :colon)
{:ok, {:provider, "model@ambiguous"}}
iex> LLMDB.Spec.parse_spec("gpt-4")
{:error, :invalid_format}
"""
@spec parse_spec(String.t() | {atom(), String.t()}, keyword()) ::
{:ok, {atom(), String.t()}}
| {:error,
:invalid_format
| :ambiguous_format
| :unknown_provider
| :bad_provider
| :invalid_chars
| :empty_segment}
def parse_spec(input, opts \\ [])
def parse_spec({provider, model_id}, _opts) when is_atom(provider) and is_binary(model_id) do
{:ok, {provider, model_id}}
end
def parse_spec(spec, opts) when is_binary(spec) do
has_colon = String.contains?(spec, ":")
has_at = String.contains?(spec, "@")
case {has_colon, has_at, Keyword.get(opts, :format)} do
{true, true, nil} ->
# When both separators present, try to determine format based on provider validity
# Try @ format first (model@provider), check if potential provider is valid
case String.split(spec, "@", parts: 2) do
[_model_part, provider_part] ->
# Check if the part after @ could be a valid provider
case parse_provider(provider_part) do
{:ok, _} ->
# @ format works, use it
parse_at_format(spec)
{:error, _} ->
# @ format doesn't work, try colon format
parse_colon_format(spec)
end
_ ->
parse_colon_format(spec)
end
{true, true, :colon} ->
parse_colon_format(spec)
{true, true, :at} ->
parse_at_format(spec)
{true, false, _} ->
parse_colon_format(spec)
{false, true, _} ->
parse_at_format(spec)
{false, false, _} ->
{:error, :invalid_format}
end
end
@doc """
Parses a model specification string, raising on error.
Same as `parse_spec/2` but raises `ArgumentError` instead of returning error tuple.
## Examples
iex> LLMDB.Spec.parse_spec!("openai:gpt-4")
{:openai, "gpt-4"}
iex> LLMDB.Spec.parse_spec!("gpt-4@openai")
{:openai, "gpt-4"}
"""
@spec parse_spec!(String.t() | {atom(), String.t()}, keyword()) :: {atom(), String.t()}
def parse_spec!(input, opts \\ []) do
case parse_spec(input, opts) do
{:ok, result} ->
result
{:error, reason} ->
raise ArgumentError,
"invalid model spec: #{inspect(input)} (#{inspect(reason)})"
end
end
@doc """
Formats a model specification as a string.
Converts a {provider, model_id} tuple to string format. The output format can be
controlled via the `format` parameter or falls back to the application config
`:llm_db, :model_spec_format` (default: `:provider_colon_model`).
## Parameters
- `spec` - {provider_atom, model_id} tuple
- `format` - Optional format override (atom)
## Supported Formats
- `:provider_colon_model` - "provider:model" (default)
- `:model_at_provider` - "model@provider" (filename-safe)
- `:filename_safe` - alias for `:model_at_provider`
## Examples
iex> LLMDB.Spec.format_spec({:openai, "gpt-4"})
"openai:gpt-4"
iex> LLMDB.Spec.format_spec({:openai, "gpt-4"}, :model_at_provider)
"gpt-4@openai"
iex> LLMDB.Spec.format_spec({:openai, "gpt-4o-mini"}, :filename_safe)
"gpt-4o-mini@openai"
"""
@spec format_spec({atom(), String.t()}, atom() | nil) :: String.t()
def format_spec({provider, model_id}, format \\ nil)
when is_atom(provider) and is_binary(model_id) do
actual_format =
format || Application.get_env(:llm_db, :model_spec_format, :provider_colon_model)
case actual_format do
:provider_colon_model ->
"#{provider}:#{model_id}"
:model_at_provider ->
"#{model_id}@#{provider}"
:filename_safe ->
"#{model_id}@#{provider}"
other ->
raise ArgumentError, "unknown format #{inspect(other)}"
end
end
@doc """
Builds a model specification string from various inputs.
Accepts strings (in any supported format) or tuples and outputs a string
in the desired format.
## Parameters
- `input` - Model spec as string or tuple
- `opts` - Keyword list with optional `:format` for output format
## Examples
iex> LLMDB.Spec.build_spec("openai:gpt-4", format: :filename_safe)
"gpt-4@openai"
iex> LLMDB.Spec.build_spec({:openai, "gpt-4"}, format: :model_at_provider)
"gpt-4@openai"
"""
@spec build_spec(String.t() | {atom(), String.t()}, keyword()) :: String.t()
def build_spec(input, opts \\ []) do
spec = normalize_spec(input)
format_spec(spec, Keyword.get(opts, :format))
end
@doc """
Normalizes a model specification to tuple format.
Accepts either a string (in any supported format) or a tuple and returns
a normalized {provider, model_id} tuple.
## Examples
iex> LLMDB.Spec.normalize_spec("openai:gpt-4")
{:openai, "gpt-4"}
iex> LLMDB.Spec.normalize_spec("gpt-4@openai")
{:openai, "gpt-4"}
iex> LLMDB.Spec.normalize_spec({:openai, "gpt-4"})
{:openai, "gpt-4"}
"""
@spec normalize_spec(String.t() | {atom(), String.t()}) :: {atom(), String.t()}
def normalize_spec({provider, model_id}) when is_atom(provider) and is_binary(model_id) do
{provider, model_id}
end
def normalize_spec(input) when is_binary(input) do
parse_spec!(input)
end
# Private parsing helpers
defp parse_colon_format(spec) do
case String.split(spec, ":", parts: 2) do
[provider_str, model_id] ->
with {:ok, _} <- validate_provider_segment(provider_str),
{:ok, _} <- validate_model_segment(model_id, :colon),
{:ok, provider_atom} <- parse_provider(provider_str) do
{:ok, {provider_atom, String.trim(model_id)}}
end
_ ->
{:error, :invalid_format}
end
end
defp parse_at_format(spec) do
case String.split(spec, "@", parts: 2) do
[model_id, provider_str] ->
with {:ok, _} <- validate_model_segment(model_id, :at),
{:ok, _} <- validate_provider_segment(provider_str),
{:ok, provider_atom} <- parse_provider(provider_str) do
{:ok, {provider_atom, String.trim(model_id)}}
end
_ ->
{:error, :invalid_format}
end
end
defp validate_provider_segment(segment) do
trimmed = String.trim(segment)
cond do
trimmed == "" ->
{:error, :empty_segment}
String.contains?(segment, ":") ->
{:error, :invalid_chars}
String.contains?(segment, "@") ->
{:error, :invalid_chars}
true ->
{:ok, segment}
end
end
defp validate_model_segment(segment, _format) do
trimmed = String.trim(segment)
cond do
trimmed == "" ->
{:error, :empty_segment}
true ->
{:ok, segment}
end
end
@doc """
Resolves a model specification to a canonical model record.
Accepts multiple input formats:
- "provider:model" string
- {provider, model_id} tuple
- Bare "model" string with opts[:scope] = provider_atom
Handles alias resolution and validates the model exists in the catalog.
## Parameters
- `input` - Model specification in one of the supported formats
- `opts` - Keyword list with optional `:scope` for bare model resolution
## Returns
- `{:ok, {provider, canonical_id, Model.t()}}` - Resolved model
- `{:error, :not_found}` - Model doesn't exist
- `{:error, :ambiguous}` - Bare model ID exists under multiple providers without scope
- `{:error, :invalid_format}` - Malformed input
- `{:error, term}` - Other parsing errors
## Examples
iex> LLMDB.Spec.resolve("openai:gpt-4")
{:ok, {:openai, "gpt-4", %LLMDB.Model{}}}
iex> LLMDB.Spec.resolve({:openai, "gpt-4"})
{:ok, {:openai, "gpt-4", %LLMDB.Model{}}}
iex> LLMDB.Spec.resolve("gpt-4", scope: :openai)
{:ok, {:openai, "gpt-4", %LLMDB.Model{}}}
iex> LLMDB.Spec.resolve("gpt-4")
{:error, :ambiguous}
"""
@spec resolve(String.t() | {atom(), String.t()}, keyword()) ::
{:ok, {atom(), String.t(), Model.t()}} | {:error, term()}
def resolve(input, opts \\ [])
def resolve(spec, opts) when is_binary(spec) do
case String.contains?(spec, ":") do
true ->
with {:ok, {provider, model_id}} <- parse_spec(spec) do
resolve_model(provider, model_id)
end
false ->
case Keyword.get(opts, :scope) do
nil -> resolve_bare_model(spec)
scope -> resolve_model(scope, spec)
end
end
end
def resolve({provider, model_id}, _opts) when is_atom(provider) and is_binary(model_id) do
resolve_model(provider, model_id)
end
def resolve(_, _), do: {:error, :invalid_format}
# Private helpers
defp verify_provider_exists(provider_atom) do
case Store.snapshot() do
%{providers_by_id: providers} when is_map(providers) ->
if Map.has_key?(providers, provider_atom) do
{:ok, provider_atom}
else
{:error, :unknown_provider}
end
_ ->
{:error, :unknown_provider}
end
end
@doc """
Strips any inference profile prefix from a model ID.
For Amazon Bedrock, splits prefixes like `"us."`, `"eu."`, `"au."` etc. from the model ID
so the base ID can be used for catalog lookup. Returns `{base_id, prefix}` where prefix
is `nil` if no prefix was found.
For other providers, returns `{model_id, nil}` unchanged.
"""
@spec strip_prefix(atom(), String.t()) :: {String.t(), String.t() | nil}
def strip_prefix(provider, model_id) do
lookup_id_and_prefix(provider, model_id)
end
defp lookup_id_and_prefix(provider, model_id) do
if provider == :amazon_bedrock do
case Enum.find_value(@bedrock_prefixes, fn prefix ->
if String.starts_with?(model_id, prefix),
do: {prefix, String.replace_prefix(model_id, prefix, "")}
end) do
nil -> {model_id, nil}
{prefix, base_id} -> {base_id, prefix}
end
else
{model_id, nil}
end
end
defp resolve_model(provider, model_id) do
case Store.snapshot() do
nil ->
{:error, :not_found}
snapshot ->
{lookup_id, prefix} = lookup_id_and_prefix(provider, model_id)
key = {provider, lookup_id}
canonical_base_id = Map.get(snapshot.aliases_by_key, key, lookup_id)
canonical_key = {provider, canonical_base_id}
case Map.get(snapshot.models_by_key, canonical_key) do
nil ->
{:error, :not_found}
model ->
returned_id =
case prefix do
nil -> canonical_base_id
p -> p <> canonical_base_id
end
{:ok, {provider, returned_id, model}}
end
end
end
defp resolve_bare_model(model_id) do
case Store.snapshot() do
nil ->
{:error, :not_found}
snapshot ->
matches = find_all_matches(snapshot, model_id)
case matches do
[] -> {:error, :not_found}
[{provider, canonical_id, model}] -> {:ok, {provider, canonical_id, model}}
[_ | _] -> {:error, :ambiguous}
end
end
end
defp find_all_matches(snapshot, model_id) do
providers = Map.keys(snapshot.providers_by_id)
Enum.flat_map(providers, fn provider ->
{lookup_id, prefix} = lookup_id_and_prefix(provider, model_id)
key = {provider, lookup_id}
canonical_base_id = Map.get(snapshot.aliases_by_key, key, lookup_id)
canonical_key = {provider, canonical_base_id}
case Map.get(snapshot.models_by_key, canonical_key) do
nil ->
[]
model ->
returned_id =
case prefix do
nil -> canonical_base_id
p -> p <> canonical_base_id
end
[{provider, returned_id, model}]
end
end)
end
end