lib/mix/tasks/openrouter.snapshot.ex

defmodule Mix.Tasks.Openrouter.Snapshot do
  @shortdoc "refresh the hard-coded openrouter model + provider snapshot"

  @moduledoc """
  fetches `/api/v1/models` and `/api/v1/providers` from openrouter and
  writes them into `priv/openrouter/`. designed for ci to call on a
  schedule and auto-open a pr when drift exists.

  ## usage

      mix openrouter.snapshot           # write snapshot, bump version
      mix openrouter.snapshot --check   # exit non-zero on drift (pr gate)

  network calls go via `:httpc` so we don't depend on a live finch
  pool while compiling. no api key is required for either endpoint.
  """

  use Mix.Task

  @models_url ~c"https://openrouter.ai/api/v1/models"
  @providers_url ~c"https://openrouter.ai/api/v1/providers"

  @impl Mix.Task
  def run(args) do
    {opts, _, _} = OptionParser.parse(args, switches: [check: :boolean])
    Application.ensure_all_started(:inets)
    Application.ensure_all_started(:ssl)

    models = fetch!(@models_url)
    providers = fetch!(@providers_url)
    version = Date.utc_today() |> Date.to_iso8601()

    targets = [
      {"priv/openrouter/models.json", encode(models)},
      {"priv/openrouter/providers.json", encode(providers)},
      {"priv/openrouter/schema_version.txt", version <> "\n"}
    ]

    if opts[:check] do
      drift = Enum.filter(targets, fn {path, expected} -> read(path) != expected end)

      if drift == [] do
        Mix.shell().info("openrouter snapshot: up to date")
      else
        Mix.shell().error("openrouter snapshot drift in:")
        Enum.each(drift, fn {path, _} -> Mix.shell().error("  #{path}") end)
        exit({:shutdown, 1})
      end
    else
      Enum.each(targets, fn {path, content} ->
        File.mkdir_p!(Path.dirname(path))
        File.write!(path, content)
        Mix.shell().info("wrote #{path}")
      end)
    end
  end

  defp fetch!(url) do
    headers = [{~c"user-agent", ~c"openrouter_sdk-snapshot/0.1"}]
    request = {url, headers}
    opts = [timeout: 30_000, ssl: ssl_opts()]

    case :httpc.request(:get, request, opts, []) do
      {:ok, {{_, 200, _}, _, body}} ->
        body |> IO.iodata_to_binary() |> decode!()

      other ->
        Mix.raise("openrouter snapshot fetch failed for #{url}: #{inspect(other)}")
    end
  end

  defp ssl_opts do
    [
      verify: :verify_peer,
      cacerts: :public_key.cacerts_get(),
      depth: 4,
      customize_hostname_check: [
        match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
      ]
    ]
  end

  # encode with stable key ordering so diffs stay clean
  defp encode(term) do
    sorted = sort(term)

    cond do
      function_exported?(JSON, :encode_to_iodata!, 1) ->
        sorted |> JSON.encode_to_iodata!() |> IO.iodata_to_binary() |> Kernel.<>("\n")

      Code.ensure_loaded?(Jason) ->
        Jason.encode_to_iodata!(sorted, pretty: false) |> IO.iodata_to_binary() |> Kernel.<>("\n")

      true ->
        Mix.raise("no json encoder available (need elixir 1.18+ or :jason)")
    end
  end

  defp decode!(binary) do
    cond do
      function_exported?(JSON, :decode!, 1) -> JSON.decode!(binary)
      Code.ensure_loaded?(Jason) -> Jason.decode!(binary)
      true -> Mix.raise("no json decoder available")
    end
  end

  # recursively sort map keys for deterministic output. lists keep their order.
  defp sort(map) when is_map(map) do
    map
    |> Enum.map(fn {k, v} -> {k, sort(v)} end)
    |> Enum.sort_by(fn {k, _} -> k end)
    |> Enum.into(%{})
  end

  defp sort(list) when is_list(list), do: Enum.map(list, &sort/1)
  defp sort(other), do: other

  defp read(path) do
    case File.read(path) do
      {:ok, content} -> content
      {:error, _} -> ""
    end
  end
end