Powered by AppSignal & Oban Pro

Imp 05: Operating Imp

livebooks/05_operating_imp.livemd

Imp 05: Operating Imp

imp_checkout? = fn path ->
  is_binary(path) and File.regular?(Path.join(path, "mix.exs")) and
    File.regular?(Path.join(path, "lib/imp.ex"))
end

explicit_repo = System.get_env("IMP_PATH")

if explicit_repo && not imp_checkout?.(Path.expand(explicit_repo)) do
  raise "IMP_PATH does not point to an Imp source checkout or unpacked package"
end

repo =
  [explicit_repo, Path.expand("..", __DIR__), File.cwd!()]
  |> Enum.reject(&is_nil/1)
  |> Enum.map(&Path.expand/1)
  |> Enum.find(imp_checkout?)

if repo do
  # Prefer the notebook's own Imp checkout even when Livebook was launched
  # from an unrelated Mix project. Unpacked package archives may omit
  # mix.lock, so pin it only when the source checkout actually provides it.
  install_opts =
    if File.regular?(Path.join(repo, "mix.lock")),
      do: [lockfile: Path.join(repo, "mix.lock")],
      else: []

  Mix.install([{:imp, path: repo}], install_opts)
else
  # Standalone notebook: install the released package from Hex.
  Mix.install([{:imp, "~> 0.5"}])
end

# Live cells run when LIVE_PROVIDER=1 and a key is set: OPENAI_API_KEY, or
# OPENROUTER_API_KEY for the same model through OpenRouter. In your own code,
# any ReqLLM model string works.
live_lm = fn opts ->
  model = System.get_env("OPENAI_MODEL", "gpt-5.4-mini")

  cond do
    System.get_env("LIVE_PROVIDER") != "1" ->
      {:skip, "Set LIVE_PROVIDER=1 and OPENAI_API_KEY to run this cell."}

    key = System.get_env("OPENAI_API_KEY") ->
      {:ok, Imp.req_llm("openai:" <> model, Keyword.put(opts, :api_key, key))}

    key = System.get_env("OPENROUTER_API_KEY") ->
      {:ok, Imp.req_llm("openrouter:openai/" <> model, Keyword.put(opts, :api_key, key))}

    true ->
      {:skip, "Set OPENAI_API_KEY to run this cell."}
  end
end

This notebook runs the pieces of Running Imp in production against the ticket router: bounded calls under a supervisor, what secrets look like in traces and saved files, telemetry, and a live check of the real model with usage and caching. Everything but the last section runs without a key.

The router

A scripted model that takes half a second, so the limits below have something to bound:

signature =
  Imp.signature(
    "ticket -> team: enum[atlas,harbor,beacon,quill]",
    "Route the support ticket to the squad that owns it."
  )

slow =
  Imp.LM.Static.new(
    handler: fn _messages, _opts ->
      Process.sleep(500)
      %{team: "atlas"}
    end
  )

router = Imp.predict(signature, lm: slow, adapter: Imp.Adapter.JSON)

Bounded calls

Imp.call/2 runs in the process that calls it. To give a call a time limit, and to limit how many run at once, run it in a task under a Task.Supervisor. A call that runs out of time is stopped, and the caller gets an error instead of waiting:

{:ok, calls} = Task.Supervisor.start_link(max_children: 2)

route = fn ticket, timeout ->
  task = Task.Supervisor.async_nolink(calls, fn -> Imp.call(router, %{ticket: ticket}) end)

  case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
    {:ok, {:ok, prediction}} -> {:ok, Imp.get(prediction, :team)}
    {:ok, {:error, reason}} -> {:error, reason}
    {:exit, reason} -> {:error, {:crashed, reason}}
    nil -> {:error, :timeout}
  end
end

{route.("We were charged twice this month.", 100),
 route.("We were charged twice this month.", 2_000)}

max_children: 2 is the concurrency limit. While two calls are running, a third is refused rather than queued:

busy = for _ <- 1..2, do: Task.Supervisor.async_nolink(calls, fn -> Process.sleep(300) end)
refused = Task.Supervisor.start_child(calls, fn -> :never_runs end)
Enum.each(busy, &Task.await/1)

refused

The deployment example wraps the same pattern in a ProgramServer that also reloads parameters while serving.

Secrets in traces

Imp.trace/2 collects the telemetry events of everything inside it. Values under key-like names are redacted before any handler sees them:

echo = Imp.tool(:echo, "Returns its arguments.", fn arguments -> arguments end)

trace =
  Imp.trace(fn ->
    Imp.Tool.call(echo, %{api_key: "sk-live", nested: %{token: "secret"}})
  end)

for {event, _measurements, metadata} <- trace.events, do: {event, metadata.arguments}

Map keys that arrive as strings, from JSON or a form, stay strings. Imp does not turn untrusted input into atoms, which the VM never garbage-collects:

key = "external_key_#{System.unique_integer([:positive])}"
example = Imp.example(%{key => "value"})

{Imp.get(example, key),
 try do
   String.to_existing_atom(key)
 rescue
   ArgumentError -> :not_an_atom
 end}

Saving without secrets

A saved program names its model but never holds the key. We bind a model again when we load it:

lm = Imp.req_llm("openai:gpt-5.4-mini", api_key: "sk-not-saved")
program = Imp.predict(signature, lm: lm, adapter: Imp.Adapter.JSON)

path = Path.join(System.tmp_dir!(), "ticket-router-#{System.unique_integer([:positive])}.json")
:ok = Imp.save!(program, path)
saved = File.read!(path)
loaded = Imp.read!(path)
File.rm!(path)

{String.contains?(saved, "sk-not-saved"), loaded.lm.model, loaded.lm.opts}

Telemetry

Imp emits :telemetry events for each program call and each model request. A handler forwards them to your metrics; this one sends them back to the notebook:

notebook = self()

:telemetry.attach_many(
  "notebook-imp",
  [[:imp, :module, :stop], [:imp, :lm, :stop]],
  fn event, %{duration: duration}, _metadata, _config ->
    send(notebook, {:imp_event, event, System.convert_time_unit(duration, :native, :millisecond)})
  end,
  nil
)

{:ok, _prediction} = Imp.call(router, %{ticket: "Deploys hang at 90%."})
:telemetry.detach("notebook-imp")

receive_events = fn receive_events, events ->
  receive do
    {:imp_event, event, milliseconds} -> receive_events.(receive_events, [{event, milliseconds} | events])
  after
    100 -> Enum.reverse(events)
  end
end

receive_events.(receive_events, [])

The scripted model makes no request, so only the program event arrives, with the half second the model took. A provider client also emits [:imp, :lm, :stop] for each request.

Live check

With a key, this cell routes one ticket twice through the real model, with usage tracking on. The first call reports its tokens; the second is the same request, answered from Imp's response cache, so it reports none. It costs a fraction of a cent.

case live_lm.(max_tokens: 200) do
  {:ok, lm} ->
    live_router = Imp.with_lm(router, lm)

    Imp.context([track_usage: true], fn ->
      for _ <- 1..2 do
        {:ok, prediction} = Imp.call(live_router, %{ticket: "Our SSO login loops back to the sign-in page."})
        team = Imp.get(prediction, :team)

        unless team in ~w(atlas harbor beacon quill) do
          raise "the model returned a team outside the signature: #{inspect(team)}"
        end

        usage =
          prediction
          |> Imp.Prediction.get_lm_usage()
          |> Map.values()
          |> Enum.map(&Map.take(&1, [:input_tokens, :output_tokens]))

        {team, usage}
      end
    end)

  skip ->
    skip
end