Skip to content

Commit ec5334a

Browse files
committed
feat(s011): the provider layer: behaviour, events, registry, req_llm adapter, retry, usage rows, fake and live suites
Trinity.LLM is the one door: a request names a registry id, the registry names the provider module, transient errors retry with backoff, a completed call writes one usage_events row with cost from the registry price. Trinity.LLM.Event fixes the seven shapes 012 and 013 consume. Trinity.LLM.Providers.ReqLLM maps requests, tools and messages to req_llm, assembles streamed tool calls from chunks and fragments, and classifies errors by looking inside req_llm's wrappers. Keys reach a provider only through Trinity.Config.secret/1. The registry lives in config/llm.exs so the live suite can read it without the environment imports. The live suite (@tag :live, excluded by default with :desktop, now stated in test_helper) found four defects on its first runs and each is fixed with its reason in the code: a caller's model id overriding the entry's model; req_llm's 30-second stream timeout under a reasoning model; an upstream 429 wrapped in an error with no status, called permanent; and an embedding model refused until the inline spec declared the capability. Ten live tests pass across OpenRouter and the NVIDIA endpoint: preflight, streamed completion, tool call, structured object on each, embeddings at dimension 2048, and a usage row from a real call. Twenty default tests through the scripted fake and the Mox mock. mix gate exit 0 (125 passed, 10 excluded). G1 lines 2 to 11. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 73caeb5 commit ec5334a

22 files changed

Lines changed: 1581 additions & 3 deletions

‎config/config.exs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ config :trinity,
3636
other -> raise "TRINITY_DB must be sqlite or postgres, got #{inspect(other)}"
3737
end)
3838

39+
# Slice 011: the model registry lives in its own file so the live test suite can read it
40+
# without evaluating the environment-specific imports below.
41+
import_config "llm.exs"
42+
3943
# Slice 010, every environment, SQLite only (the Postgres adapter ignores keys it does not
4044
# know, and the CI matrix proves that). One writer: the pool has exactly one connection, so the
4145
# single-writer rule SQLite imposes is the pool's shape rather than a hope. Each pragma is named

‎config/llm.exs‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
import Config
4+
5+
# Slice 011: the model registry. Ids are Trinity's; `model` is req_llm's "provider:model";
6+
# keys are read through Trinity.Config.secret/1 from the named variable at call time. Prices
7+
# are US dollars per million tokens and are the source of every recorded cost. Free-tier
8+
# models carry 0.0. The default model is the OpenRouter one the owner picked; the NVIDIA
9+
# endpoint is reached as an OpenAI-compatible base_url. Test config replaces all of this.
10+
config :trinity, :llm,
11+
default_model: "openrouter:ling",
12+
providers: %{req_llm: Trinity.LLM.Providers.ReqLLM},
13+
retry: [attempts: 3, base_ms: 200],
14+
models: [
15+
%{
16+
id: "openrouter:ling",
17+
provider: :req_llm,
18+
model:
19+
"openrouter:" <>
20+
System.get_env("TRINITY_LIVE_MODEL", "inclusionai/ling-3.0-flash-vl:free"),
21+
api_key_env: "OPENROUTER_API_KEY",
22+
caps: [:stream, :tools, :json],
23+
price: %{input: 0.0, output: 0.0}
24+
},
25+
%{
26+
id: "nvidia:nemotron",
27+
provider: :req_llm,
28+
model:
29+
"openai:" <> System.get_env("NEMOTRON_MODEL", "nvidia/nemotron-3.5-lightning-30b-a3b"),
30+
base_url: System.get_env("NEMOTRON_BASE_URL", "https://integrate.api.nvidia.com/v1"),
31+
api_key_env: "NEMOTRON_API_KEY",
32+
caps: [:stream, :tools, :json],
33+
price: %{input: 0.0, output: 0.0}
34+
},
35+
%{
36+
id: "nvidia:embed",
37+
provider: :req_llm,
38+
model: "openai:nvidia/nemotron-3-embed-1b",
39+
base_url: System.get_env("NEMOTRON_BASE_URL", "https://integrate.api.nvidia.com/v1"),
40+
api_key_env: "NEMOTRON_API_KEY",
41+
caps: [:embed],
42+
price: %{input: 0.0, output: 0.0}
43+
}
44+
]

‎config/test.exs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,36 @@ import Config
77
# The MIX_TEST_PARTITION environment variable can be used
88
# to provide built-in test partitioning in CI environment.
99
# Run `mix help test` for more information.
10+
# Slice 011: the registry in tests is the scripted fake plus a Mox mock; the live tests set
11+
# their own entries from the environment at runtime.
12+
config :trinity, :llm,
13+
default_model: "fake:chat",
14+
providers: %{fake: Trinity.LLM.Providers.Fake, mock: Trinity.LLM.ProviderMock},
15+
retry: [attempts: 3, base_ms: 1],
16+
models: [
17+
%{
18+
id: "fake:chat",
19+
provider: :fake,
20+
model: "chat",
21+
caps: [:stream, :tools, :json],
22+
price: %{input: 1.0, output: 2.0}
23+
},
24+
%{
25+
id: "fake:embed",
26+
provider: :fake,
27+
model: "embed",
28+
caps: [:embed, {:embed_dim, 8}],
29+
price: %{input: 0.5, output: 0.0}
30+
},
31+
%{
32+
id: "mock:chat",
33+
provider: :mock,
34+
model: "chat",
35+
caps: [:stream, :tools],
36+
price: %{input: 0.0, output: 0.0}
37+
}
38+
]
39+
1040
# Slice 010: the data-dir lock takes a temporary directory in tests, so a test run never
1141
# contends with a running Trinity on the same machine, and two test runs at once do contend,
1242
# which is the property under test.

‎lib/trinity.ex‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ defmodule Trinity do
1010
use Boundary,
1111
deps: [],
1212
exports:
13-
[Paths, Repo, UUID, Sessions] ++
13+
[Paths, Repo, UUID, Config, Sessions, LLM] ++
1414
if(Mix.env() == :test, do: [DataCase, NetworkGuard, Factory], else: [])
1515

1616
@moduledoc """

‎lib/trinity/application.ex‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ defmodule Trinity.Application do
3333
repos: Application.fetch_env!(:trinity, :ecto_repos), skip: skip_migrations?()},
3434
{DNSCluster, query: Application.get_env(:trinity, :dns_cluster_query) || :ignore},
3535
{Phoenix.PubSub, name: Trinity.PubSub},
36+
# Slice 011: streams to a pid run under this supervisor, never as bare tasks.
37+
{Task.Supervisor, name: Trinity.LLM.TaskSupervisor},
3638
# Start to serve requests, typically the last entry
3739
TrinityWeb.Endpoint
3840
] ++ Trinity.Smoke.children(Trinity.Smoke.argv())

‎lib/trinity/config.ex‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.Config do
4+
@moduledoc """
5+
Secrets, read from one place. Slice 011: the environment. Slice 100 adds the OS keychain
6+
behind the same function, and nothing else in the tree reads a key any other way.
7+
"""
8+
9+
@doc "The secret named by `env_var`, or a named error; never nil handed to a provider."
10+
@spec secret(String.t()) :: {:ok, String.t()} | {:error, {:missing_secret, String.t()}}
11+
def secret(env_var) when is_binary(env_var) do
12+
case System.get_env(env_var) do
13+
value when is_binary(value) and value != "" -> {:ok, value}
14+
_ -> {:error, {:missing_secret, env_var}}
15+
end
16+
end
17+
end

‎lib/trinity/llm.ex‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.LLM do
4+
@moduledoc """
5+
The one door to a language model. Slice 011.
6+
7+
Sessions call this and never a provider. A request names a registry model id (or nothing, for
8+
the default); the registry names the provider module; the call is retried on transient errors;
9+
a completed call writes a `usage_events` row with cost from the registry price. Events reach
10+
the caller either through a function (`stream/3`) or as messages to a pid (`stream_to/3`),
11+
which is what slice 012's Session wants.
12+
"""
13+
use Boundary, deps: [Trinity], exports: [Error, Event, Request, Provider, Registry]
14+
15+
alias Trinity.LLM.{Error, Registry, Request, Retry, Usage}
16+
17+
@type opts :: keyword()
18+
19+
@doc "Streams events through `emit`, in the calling process. Returns the usage."
20+
@spec stream(Request.t(), opts(), Trinity.LLM.Provider.emit()) ::
21+
{:ok, map()} | {:error, Error.t() | term()}
22+
def stream(%Request{} = request, opts \\ [], emit) when is_function(emit, 1) do
23+
with {:ok, entry, module} <- resolve(request) do
24+
call(entry, "chat", opts, fn ->
25+
module.stream(request, provider_opts(entry, opts), emit)
26+
end)
27+
end
28+
end
29+
30+
@doc """
31+
Streams events as messages `{:llm_event, ref, event}` to `pid`, then `{:llm_done, ref, result}`.
32+
Runs in a supervised task so the caller is never blocked; `ref` is returned at once.
33+
"""
34+
@spec stream_to(Request.t(), opts(), pid()) :: {:ok, reference()} | {:error, term()}
35+
def stream_to(%Request{} = request, opts \\ [], pid) when is_pid(pid) do
36+
ref = make_ref()
37+
38+
with {:ok, _entry, _module} <- resolve(request),
39+
{:ok, _task} <-
40+
Task.Supervisor.start_child(Trinity.LLM.TaskSupervisor, fn ->
41+
result = stream(request, opts, &send(pid, {:llm_event, ref, &1}))
42+
send(pid, {:llm_done, ref, result})
43+
end) do
44+
{:ok, ref}
45+
end
46+
end
47+
48+
@doc "One complete response."
49+
@spec generate(Request.t(), opts()) :: {:ok, Trinity.LLM.Provider.result()} | {:error, term()}
50+
def generate(%Request{} = request, opts \\ []) do
51+
with {:ok, entry, module} <- resolve(request) do
52+
call(entry, "chat", opts, fn -> module.generate(request, provider_opts(entry, opts)) end)
53+
end
54+
end
55+
56+
@doc "A map validated against a JSON Schema."
57+
@spec generate_object(Request.t(), map(), opts()) :: {:ok, map()} | {:error, term()}
58+
def generate_object(%Request{} = request, schema, opts \\ []) when is_map(schema) do
59+
with {:ok, entry, module} <- resolve(request),
60+
{:ok, object, _usage} <-
61+
call(entry, "object", opts, fn ->
62+
module.generate_object(request, schema, provider_opts(entry, opts))
63+
end) do
64+
{:ok, object}
65+
end
66+
end
67+
68+
@doc "One vector per text. `opts[:model]` names the embedding model's registry id."
69+
@spec embed([String.t()], opts()) :: {:ok, [[float()]]} | {:error, term()}
70+
def embed(texts, opts \\ []) when is_list(texts) do
71+
with {:ok, entry} <- Registry.lookup(Keyword.get(opts, :model)),
72+
{:ok, module} <- Registry.provider_module(entry),
73+
{:ok, vectors, _usage} <-
74+
call(entry, "embed", opts, fn -> module.embed(texts, provider_opts(entry, opts)) end) do
75+
{:ok, vectors}
76+
end
77+
end
78+
79+
@doc "The registry's models."
80+
@spec models() :: [Registry.entry()]
81+
def models, do: Registry.models()
82+
83+
@doc "The registry's default model id."
84+
@spec default_model() :: String.t() | nil
85+
def default_model, do: Registry.default_model()
86+
87+
@doc "A model's capabilities, from its registry entry."
88+
@spec capabilities(String.t()) :: {:ok, [atom() | {atom(), term()}]} | {:error, term()}
89+
def capabilities(model_id) do
90+
with {:ok, entry} <- Registry.lookup(model_id), do: {:ok, entry.caps}
91+
end
92+
93+
defp resolve(%Request{model: model}) do
94+
with {:ok, entry} <- Registry.lookup(model),
95+
{:ok, module} <- Registry.provider_module(entry) do
96+
{:ok, entry, module}
97+
end
98+
end
99+
100+
# The entry's keys win: a caller's `:model` is a registry id, the entry's is the provider's
101+
# own name, and the provider must see the latter. Found by the live suite, where an embed
102+
# call asked req_llm for a provider named after the registry id.
103+
defp provider_opts(entry, opts) do
104+
entry_opts =
105+
entry |> Map.take([:model, :base_url, :api_key_env, :price, :caps]) |> Map.to_list()
106+
107+
Keyword.merge(opts, entry_opts)
108+
end
109+
110+
# Retry around the provider; on success the usage is recorded. The usage row is written for
111+
# the call that completed, once, whatever the number of attempts it took.
112+
defp call(entry, kind, opts, fun) do
113+
case Retry.run(fun, opts) do
114+
{:ok, %{usage: usage}} = ok ->
115+
record(entry, kind, usage, opts)
116+
ok
117+
118+
{:ok, usage} = ok when is_map(usage) ->
119+
record(entry, kind, usage, opts)
120+
ok
121+
122+
{:ok, _value, usage} = ok ->
123+
record(entry, kind, usage, opts)
124+
ok
125+
126+
other ->
127+
other
128+
end
129+
end
130+
131+
defp record(entry, kind, usage, opts) do
132+
{:ok, _} = Usage.record(entry, kind, usage, Keyword.take(opts, [:session_id]))
133+
end
134+
end

‎lib/trinity/llm/error.ex‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.LLM.Error do
4+
@moduledoc """
5+
A provider failure with one bit that matters to the caller: whether trying again could help.
6+
Timeouts, rate limits, server errors and refused connections are transient; everything else
7+
(a bad key, an unknown model, a malformed request) is not and is returned at once.
8+
"""
9+
10+
@type t :: %__MODULE__{transient?: boolean(), reason: term(), status: non_neg_integer() | nil}
11+
defexception [:reason, :status, transient?: false]
12+
13+
@impl true
14+
def message(%__MODULE__{transient?: t, status: status, reason: reason}) do
15+
kind = if t, do: "transient", else: "permanent"
16+
"#{kind} LLM error#{if status, do: " (HTTP #{status})", else: ""}: #{inspect(reason)}"
17+
end
18+
19+
@doc "Classifies an HTTP status: 408, 425, 429 and 5xx are transient."
20+
@spec from_status(non_neg_integer(), term()) :: t()
21+
def from_status(status, reason) do
22+
%__MODULE__{
23+
status: status,
24+
reason: reason,
25+
transient?: status in [408, 425, 429] or status >= 500
26+
}
27+
end
28+
29+
@doc "A transient error with no status: a timeout, a closed or refused connection."
30+
@spec transient(term()) :: t()
31+
def transient(reason), do: %__MODULE__{reason: reason, transient?: true}
32+
33+
@doc "A permanent error with no status."
34+
@spec permanent(term()) :: t()
35+
def permanent(reason), do: %__MODULE__{reason: reason, transient?: false}
36+
end

‎lib/trinity/llm/event.ex‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.LLM.Event do
4+
@moduledoc """
5+
The event stream shape every provider emits and every consumer reads. Slice 011.
6+
7+
Seven shapes, from SLICE.md, and nothing else. Slice 012's Session and slice 013's UI consume
8+
these and never a provider's own chunks; `valid?/1` is the guard a provider's output is
9+
held to in tests.
10+
"""
11+
12+
@type tool_call_id :: String.t()
13+
@type t ::
14+
{:text_delta, String.t()}
15+
| {:tool_call_start, tool_call_id(), name :: String.t()}
16+
| {:tool_call_delta, tool_call_id(), json_chunk :: String.t()}
17+
| {:tool_call_end, tool_call_id(), args :: map()}
18+
| {:usage, map()}
19+
| {:done, reason :: :stop | :length | :tool_calls | :content_filter | atom()}
20+
| {:error, term()}
21+
22+
@doc "True for exactly the seven shapes above."
23+
@spec valid?(term()) :: boolean()
24+
def valid?({:text_delta, s}) when is_binary(s), do: true
25+
def valid?({:tool_call_start, id, name}) when is_binary(id) and is_binary(name), do: true
26+
def valid?({:tool_call_delta, id, chunk}) when is_binary(id) and is_binary(chunk), do: true
27+
def valid?({:tool_call_end, id, args}) when is_binary(id) and is_map(args), do: true
28+
def valid?({:usage, usage}) when is_map(usage), do: true
29+
def valid?({:done, reason}) when is_atom(reason), do: true
30+
def valid?({:error, _}), do: true
31+
def valid?(_), do: false
32+
end

‎lib/trinity/llm/provider.ex‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.LLM.Provider do
4+
@moduledoc """
5+
The behaviour every provider implements and the only thing `Trinity.LLM` calls. Slice 011.
6+
7+
`opts` carries what the registry entry says about the model (`:model`, the provider's own
8+
model name; `:base_url` and `:api_key` for OpenAI-compatible endpoints; `:price`) plus what
9+
the caller passed. A provider emits `Trinity.LLM.Event` shapes through `emit` in the calling
10+
process and returns when the stream ends. Errors are `Trinity.LLM.Error` structs.
11+
"""
12+
13+
alias Trinity.LLM.{Error, Request}
14+
15+
@type opts :: keyword()
16+
@type emit :: (Trinity.LLM.Event.t() -> any())
17+
@type usage :: %{
18+
optional(:input_tokens) => non_neg_integer(),
19+
optional(:output_tokens) => non_neg_integer(),
20+
optional(:cached_tokens) => non_neg_integer(),
21+
optional(:reasoning_tokens) => non_neg_integer(),
22+
optional(:provider_cost) => number() | nil
23+
}
24+
@type result :: %{
25+
text: String.t(),
26+
tool_calls: [%{id: String.t(), name: String.t(), args: map()}],
27+
usage: usage(),
28+
finish: atom()
29+
}
30+
31+
@doc "Streams events through `emit`; returns the usage when the stream ends."
32+
@callback stream(Request.t(), opts(), emit()) :: {:ok, usage()} | {:error, Error.t()}
33+
34+
@doc "One complete response."
35+
@callback generate(Request.t(), opts()) :: {:ok, result()} | {:error, Error.t()}
36+
37+
@doc "A map validated against `schema` (a JSON Schema), with the usage beside it."
38+
@callback generate_object(Request.t(), schema :: map(), opts()) ::
39+
{:ok, map(), usage()} | {:error, Error.t()}
40+
41+
@doc "One vector per text, of the dimension `capabilities/1` declares."
42+
@callback embed([String.t()], opts()) :: {:ok, [[float()]], usage()} | {:error, Error.t()}
43+
44+
@doc "The provider's own model names it can serve, for diagnostics."
45+
@callback models() :: [String.t()]
46+
47+
@doc "Capabilities of a model: `:stream`, `:tools`, `:json`, `:embed`, `{:embed_dim, n}`."
48+
@callback capabilities(model :: String.t()) :: [atom() | {atom(), term()}]
49+
end

0 commit comments

Comments
 (0)