lib/allm/capability.ex

defmodule ALLM.Capability do
  @moduledoc """
  Layer B — optional model-catalog integration via the `LLMDB` Hex package.

  Phase 9.4 ships three helpers, all gated on `Code.ensure_loaded?(LLMDB)`:

    * `preflight/2` — pre-flights tool / `response_format` capability against
      the catalog's `%ALLM.ModelRef{}` and surfaces a
      `%ALLM.Error.ValidationError{reason: :unsupported_capability}` before
      the adapter sees a request it can't satisfy.
    * `preflight_image/2` (Phase 14.3) — sister of `preflight/3` for
      `ALLM.ImageRequest`: rejects requests against models with
      `images_enabled: false` or whose `supported_image_operations` does
      not include the requested op. 2-arity, two-shape return
      (`:ok | {:error, _}` — no rewrite branch).
    * `populate_costs/2` — fills `Usage.{input_cost, output_cost,
      total_cost}` from the catalog's per-million-token pricing after the
      adapter has reported token counts.
    * `select/1` — delegates to `LLMDB.select/1` for capability-based model
      selection (`require:` / `prefer:`).

  ## Why this is integration-by-detection, not a Hex dep

  `mix.exs` does NOT list `:llm_db` as a dep — see the Phase 9 design's
  Non-obvious Decision #6. ALLM detects the catalog at runtime via
  `Code.ensure_loaded?(Module.concat(["LLMDB"]))`. Application users who
  want capability pre-flight and cost population add `:llm_db` to their
  own `mix.exs`; ALLM picks it up automatically. Tests use the
  `test/support/llm_db.ex` fake (compiled only in `:test` via
  `elixirc_paths(:test)`) which mimics the published-package surface
  verbatim (no `ALLM.` prefix).

  ## What pre-flight rejects

  Two rules in v0.2:

    * **Tools against a tools-disabled model** — `request.tools != []` AND
      `model_ref.capabilities.tools.enabled == false`.
    * **`response_format: %{type: :json_schema, ...}` against a
      non-`json_native` model** — `model_ref.capabilities.json_native ==
      false`.

  `:json_object` is the soft-capability carve-out — it does NOT require a
  structured-output schema enforcer; pre-flight does NOT reject
  `:json_object` requests against a non-`json_native` model (graceful
  degradation, not an error).

  Both rejections accumulate when both fire; the resulting
  `%ValidationError{}` carries two field-error tuples in `:errors`.

  ## JSON-rehydrated `%ModelRef{}` tolerance (Finding #1)

  `ALLM.ModelRef`'s opaque map fields (`:capabilities`, `:limits`,
  `:pricing`, `:metadata`) are documented as Layer-A-asymmetric: ETF
  round-trip is byte-identical, but JSON round-trip preserves only the
  outer struct shape — nested map keys come back as STRINGS (matching
  the Phase 1 `Engine.metadata` carve-out). Rather than restoring atoms
  in `ModelRef.__from_tagged__/1` (which would require a closed
  capability-key allowlist and tighten the surface), the consumer is
  made tolerant: `check_tools/3` and `check_json_native/3` pattern-match
  on **both** atom-keyed (`%{tools: %{enabled: false}}`) and
  string-keyed (`%{"tools" => %{"enabled" => false}}`) shapes so a
  rehydrated ref pre-flights identically to an in-process one.

  ## Where pre-flight runs

  Wired into `ALLM.StreamRunner.run/3`'s `with`-chain after
  `ALLM.Validate.request/1` and after `Engine.resolve_model/2` (the
  resolved model is passed to `preflight/2`). `Runner.run/3` delegates to
  `StreamRunner.run/3`, so the wire-up appears once. For multi-turn
  `ALLM.Chat` paths, pre-flight runs once at the first adapter call —
  the model doesn't change mid-conversation.

  ## Cost units

  Pricing is per-million-tokens (the `llm_db` convention; spec §6.3 leaves
  units unspecified). Math: `input_cost = pricing.input * input_tokens /
  1_000_000`. `total_cost` is computed only when both `input_cost` and
  `output_cost` are populated. `populate_costs/2` NEVER overwrites a
  non-nil cost field; it only fills `nil`.
  """

  alias ALLM.Error.ValidationError
  alias ALLM.ImagePart
  alias ALLM.ImageRequest
  alias ALLM.Message
  alias ALLM.ModelRef
  alias ALLM.Request
  alias ALLM.Usage

  @typedoc """
  Result of a pre-flight capability check. Three shapes (Phase 10.4 widened):

    * `:ok` — no rewrite needed, no rejection. The caller dispatches the
      original request unchanged.
    * `{:ok, %Request{}}` — pre-flight rewrote the request (e.g. set
      `structured_finalize: true`); the caller MUST dispatch the returned
      request, not the original.
    * `{:error, %ValidationError{}}` — pre-flight rejected the request;
      the caller MUST surface the error and not dispatch.
  """
  @type preflight_result :: :ok | {:ok, Request.t()} | {:error, ValidationError.t()}

  @typedoc "Either a resolved `%ModelRef{}` from the catalog, a raw model string/tuple, or `nil`."
  @type model_ref_or_string :: ModelRef.t() | String.t() | tuple() | nil

  @doc """
  Return `true` when the optional `LLMDB` catalog module is loaded into the
  BEAM AND the test override (`Application.put_env(:allm,
  :force_capability_absent, true)`) is NOT set.

  The override seam exists for the dep-free smoke test — see Phase 9.4
  Non-obvious Decision #5. `:code.delete/1` + `:code.purge/1` does NOT
  work because `test/support/llm_db.ex` re-loads from `_build` on the
  next `Code.ensure_loaded?/1`; the application-env override is the
  reliable simulator.

  Use the `Module.concat(["LLMDB"])` idiom to keep the dep optional —
  the literal-string-list argument produces a single atom at runtime
  with no compile-time reference (the same pattern as
  `ALLM.Engine.resolve_model/2` at `lib/allm/engine.ex:301-304`).

  ## Examples

      iex> is_boolean(ALLM.Capability.catalog_loaded?())
      true
  """
  @spec catalog_loaded?() :: boolean()
  def catalog_loaded? do
    if Application.get_env(:allm, :force_capability_absent, false) == true do
      false
    else
      Code.ensure_loaded?(Module.concat(["LLMDB"]))
    end
  end

  @doc """
  Pre-flight a request against the catalog's view of a model.

  ## Three return shapes (Phase 10.4)

    * `:ok` — no rewrite needed, no rejection. Caller dispatches the
      original request unchanged. Returned when the catalog is absent,
      when the model is a bare string/tuple/`nil` (no capability info),
      or when no rejection rule fires AND no rewrite predicate matches.
    * `{:ok, %Request{}}` — pre-flight rewrote the request. v0.2's only
      rewrite is `structured_finalize: true` (auto-set when the adapter's
      `requires_structured_finalize?/1` returns `true` for a request that
      combines tools and a `json_schema` response_format). Caller MUST
      dispatch the returned request.
    * `{:error, %ValidationError{reason: :unsupported_capability, errors: [...]}}`
      — pre-flight rejected the request. Both rejection rules accumulate
      in `:errors` when both fire.

  ## Adapter argument (Phase 10.4 — optional)

  The third argument carries the adapter module so pre-flight can call
  `function_exported?(adapter, :requires_structured_finalize?, 1)` to
  decide the rewrite. Defaults to `nil` (no rewrite) so existing 2-arg
  callers continue to work — the rewrite branch only fires when the
  caller threads the adapter through. Per design Decision #14,
  `requires_structured_finalize?/1` is a regular module function, NOT a
  `@callback`, so most adapters do not export it.

  ## Examples

      iex> ALLM.Capability.preflight("openai:gpt-4.1-mini", ALLM.request([ALLM.user("hi")]))
      :ok

      iex> ref = ALLM.ModelRef.new(
      ...>   provider: :local, id: "no-tools",
      ...>   capabilities: %{tools: %{enabled: false}, json_native: true}
      ...> )
      iex> tool = ALLM.Tool.new(name: "echo", description: "x", schema: %{})
      iex> req = ALLM.Request.new([%ALLM.Message{role: :user, content: "hi"}], tools: [tool])
      iex> {:error, err} = ALLM.Capability.preflight(ref, req)
      iex> err.reason
      :unsupported_capability
      iex> err.errors
      [{[:tools], :tools_disabled}]
  """
  @spec preflight(model_ref_or_string(), Request.t(), module() | nil) :: preflight_result()
  def preflight(model_ref_or_string, %Request{} = request, adapter \\ nil) do
    cond do
      not catalog_loaded?() ->
        maybe_structured_finalize_rewrite(:ok, request, adapter)

      not is_struct(model_ref_or_string, ModelRef) ->
        maybe_structured_finalize_rewrite(:ok, request, adapter)

      true ->
        model_ref_or_string
        |> check_capabilities(request)
        |> maybe_structured_finalize_rewrite(request, adapter)
    end
  end

  # Phase 10.4 — auto-set `structured_finalize: true` when the adapter
  # exports `requires_structured_finalize?/1` AND it returns `true` for
  # this request. Idempotent: if the request already carries
  # `structured_finalize: true` we keep `:ok` (no-op rewrite). Errors
  # pass through unchanged. See spec §5.4 and design Decision #2.
  defp maybe_structured_finalize_rewrite({:error, _} = err, _request, _adapter), do: err

  defp maybe_structured_finalize_rewrite(:ok, %Request{structured_finalize: true}, _adapter),
    do: :ok

  defp maybe_structured_finalize_rewrite(:ok, %Request{} = request, adapter)
       when is_atom(adapter) and not is_nil(adapter) do
    if Code.ensure_loaded?(adapter) and
         function_exported?(adapter, :requires_structured_finalize?, 1) and
         adapter.requires_structured_finalize?(request) == true do
      {:ok, %Request{request | structured_finalize: true}}
    else
      :ok
    end
  end

  defp maybe_structured_finalize_rewrite(:ok, _request, _adapter), do: :ok

  @typedoc "Two-shape result of `preflight_image/2` (no rewrite branch)."
  @type image_preflight_result :: :ok | {:error, ValidationError.t()}

  @doc """
  Pre-flight an `ALLM.ImageRequest` against the catalog's view of a
  model — sister of `preflight/3`, narrower contract.

  Returns `:ok | {:error, %ValidationError{reason: :unsupported_capability}}`
  only — there is no `{:ok, %ImageRequest{}}` rewrite branch (image
  requests have no analogous rewrite need in v0.3, per Phase 14.3
  design Decision #10). 2-arity by design — symmetric with
  `populate_costs/2`, NOT with `preflight/3`.

  ## Rejection rules (both accumulate when both fire)

    * `{[:images_enabled], :images_disabled}` — fires when
      `model_ref.capabilities.images_enabled == false`.
    * `{[:operation], :unsupported_image_operation}` — fires when
      `request.operation not in model_ref.capabilities.supported_image_operations`.

  Tolerates JSON-rehydrated `%ModelRef{}` with string-keyed
  capabilities (`%{"images_enabled" => false, "supported_image_operations" => ["generate"]}`)
  per the existing pattern in `check_tools/3` / `check_json_native/3`.

  Returns `:ok` early when the catalog is absent
  (`catalog_loaded?/0 == false`) or when `model_ref_or_string` is a bare
  string / tuple / `nil` (no capability info).

  ## Examples

      iex> req = ALLM.ImageRequest.new(prompt: "a kestrel")
      iex> ALLM.Capability.preflight_image("openai:gpt-image-1", req)
      :ok

      iex> ref = ALLM.ModelRef.new(
      ...>   provider: :local, id: "no-images",
      ...>   capabilities: %{images_enabled: false, supported_image_operations: []}
      ...> )
      iex> req = ALLM.ImageRequest.new(prompt: "a kestrel")
      iex> {:error, err} = ALLM.Capability.preflight_image(ref, req)
      iex> err.reason
      :unsupported_capability
      iex> err.errors
      [{[:images_enabled], :images_disabled}, {[:operation], :unsupported_image_operation}]
  """
  @spec preflight_image(model_ref_or_string(), ImageRequest.t()) :: image_preflight_result()
  def preflight_image(model_ref_or_string, %ImageRequest{} = request) do
    cond do
      not catalog_loaded?() ->
        :ok

      not is_struct(model_ref_or_string, ModelRef) ->
        :ok

      true ->
        check_image_capabilities(model_ref_or_string, request)
    end
  end

  @doc """
  Populate `Usage.{input_cost, output_cost, total_cost}` from
  `model_ref.pricing` (per-million-token rates).

  Returns the input usage unchanged when the catalog is absent, when the
  model is a bare string/tuple/`nil`, or when `model_ref.pricing == nil`.
  Partial population is allowed: when `:input_tokens` is `nil`,
  `:input_cost` stays `nil`; `:output_cost` can still populate from
  `:output_tokens`. `:total_cost` is populated only when both partial
  costs are present. NEVER overwrites a non-nil cost field — only fills
  `nil` (Phase 9 design Invariant 8).

  ## Examples

      iex> ref = ALLM.ModelRef.new(provider: :openai, id: "x", pricing: %{input: 0.15, output: 0.6})
      iex> usage = %ALLM.Usage{input_tokens: 1000, output_tokens: 500}
      iex> populated = ALLM.Capability.populate_costs(usage, ref)
      iex> populated.input_cost
      1.5e-4
      iex> populated.output_cost
      3.0e-4
      iex> populated.total_cost
      4.5e-4
  """
  @spec populate_costs(Usage.t(), model_ref_or_string()) :: Usage.t()
  def populate_costs(%Usage{} = usage, model_ref_or_string) do
    cond do
      not catalog_loaded?() -> usage
      not is_struct(model_ref_or_string, ModelRef) -> usage
      is_nil(model_ref_or_string.pricing) -> usage
      true -> apply_pricing(usage, model_ref_or_string.pricing)
    end
  end

  @doc """
  Delegate to `LLMDB.select/1` for capability-based model selection.

  Returns `LLMDB.select(criteria)` when the catalog is loaded; returns
  `{:error, :catalog_not_loaded}` (atom shape, not a struct — the only
  `:error` shape this module produces with an atom reason) otherwise.

  ## Examples

      iex> match?({:error, :catalog_not_loaded}, ALLM.Capability.select(require: [:tools])) or
      ...>   match?({:ok, _}, ALLM.Capability.select(require: [:tools])) or
      ...>   match?({:error, _}, ALLM.Capability.select(require: [:tools]))
      true
  """
  @spec select(keyword()) ::
          {:ok, ModelRef.t()} | {:error, :catalog_not_loaded | :no_match | term()}
  def select(criteria) when is_list(criteria) do
    if catalog_loaded?() do
      Module.concat(["LLMDB"]).select(criteria)
    else
      {:error, :catalog_not_loaded}
    end
  end

  # ---------------------------------------------------------------------------
  # Private — preflight checks
  # ---------------------------------------------------------------------------

  defp check_capabilities(%ModelRef{} = ref, %Request{} = request) do
    errors =
      []
      |> check_tools(ref, request)
      |> check_json_native(ref, request)
      |> check_vision(ref, request)
      |> Enum.reverse()

    case errors do
      [] ->
        :ok

      list ->
        {:error,
         ValidationError.new(:unsupported_capability, list,
           message: "model does not support requested capabilities"
         )}
    end
  end

  defp check_tools(acc, %ModelRef{capabilities: caps}, %Request{tools: tools})
       when is_list(tools) and tools != [] do
    # Tolerate JSON-rehydrated %ModelRef{} with string-keyed capabilities
    # (see @moduledoc "JSON-rehydrated %ModelRef{} tolerance"). Atom-keyed
    # nested map (in-process) and string-keyed (post-Jason round-trip) both
    # reject. Anything else passes.
    case caps do
      %{tools: %{enabled: false}} -> [{[:tools], :tools_disabled} | acc]
      %{"tools" => %{"enabled" => false}} -> [{[:tools], :tools_disabled} | acc]
      _ -> acc
    end
  end

  defp check_tools(acc, _ref, _req), do: acc

  defp check_json_native(acc, %ModelRef{capabilities: caps}, %Request{
         response_format: %{type: :json_schema}
       }) do
    # Tolerate JSON-rehydrated %ModelRef{} with string-keyed capabilities
    # (see @moduledoc "JSON-rehydrated %ModelRef{} tolerance").
    case caps do
      %{json_native: false} -> [{[:response_format], :json_native_disabled} | acc]
      %{"json_native" => false} -> [{[:response_format], :json_native_disabled} | acc]
      _ -> acc
    end
  end

  defp check_json_native(acc, _ref, _req), do: acc

  # Phase 17.1 — vision capability gate (§35.6, design Decision #5).
  # Fires when the request contains any %ImagePart{} AND the resolved
  # model's capabilities map says `vision: false` (atom-keyed) or
  # `"vision" => false` (string-keyed JSON-rehydrated). When the catalog
  # has no `:vision` key at all, no-op (graceful degradation matches the
  # `:tools_disabled` precedent).
  defp check_vision(acc, %ModelRef{capabilities: caps}, %Request{messages: messages}) do
    if request_has_image_part?(messages) do
      case caps do
        %{vision: false} -> [{[:vision], :vision_disabled} | acc]
        %{"vision" => false} -> [{[:vision], :vision_disabled} | acc]
        _ -> acc
      end
    else
      acc
    end
  end

  defp request_has_image_part?(messages) when is_list(messages) do
    Enum.any?(messages, fn
      %Message{content: content} when is_list(content) ->
        Enum.any?(content, &match?(%ImagePart{}, &1))

      _ ->
        false
    end)
  end

  # ---------------------------------------------------------------------------
  # Private — image preflight (Phase 14.3)
  # ---------------------------------------------------------------------------

  defp check_image_capabilities(%ModelRef{} = ref, %ImageRequest{} = request) do
    errors =
      []
      |> check_images_enabled(ref)
      |> check_supported_image_operation(ref, request)
      |> Enum.reverse()

    case errors do
      [] ->
        :ok

      list ->
        {:error,
         ValidationError.new(:unsupported_capability, list,
           message: "model does not support requested image capabilities"
         )}
    end
  end

  defp check_images_enabled(acc, %ModelRef{capabilities: caps}) do
    # Tolerate JSON-rehydrated %ModelRef{} with string-keyed capabilities
    # (see @moduledoc "JSON-rehydrated %ModelRef{} tolerance"). Atom-keyed
    # nested map (in-process) and string-keyed (post-Jason round-trip) both
    # reject. Anything else passes.
    case caps do
      %{images_enabled: false} -> [{[:images_enabled], :images_disabled} | acc]
      %{"images_enabled" => false} -> [{[:images_enabled], :images_disabled} | acc]
      _ -> acc
    end
  end

  defp check_supported_image_operation(acc, %ModelRef{capabilities: caps}, %ImageRequest{
         operation: op
       }) do
    # Tolerate string-keyed capabilities and string-encoded atoms (the
    # JSON encoder for capability lists emits `["generate"]` from
    # `[:generate]`).
    case caps do
      %{supported_image_operations: ops} when is_list(ops) ->
        if op in ops, do: acc, else: [{[:operation], :unsupported_image_operation} | acc]

      %{"supported_image_operations" => ops} when is_list(ops) ->
        if Atom.to_string(op) in ops or op in ops,
          do: acc,
          else: [{[:operation], :unsupported_image_operation} | acc]

      _ ->
        # No supported_image_operations key — don't reject (no info).
        acc
    end
  end

  # ---------------------------------------------------------------------------
  # Private — cost math
  # ---------------------------------------------------------------------------

  defp apply_pricing(%Usage{} = usage, pricing) do
    # Tolerate JSON-rehydrated %ModelRef{} with string-keyed pricing
    # (see @moduledoc "JSON-rehydrated %ModelRef{} tolerance").
    input_rate = pricing[:input] || pricing["input"]
    output_rate = pricing[:output] || pricing["output"]
    input_cost = compute_cost(usage.input_cost, usage.input_tokens, input_rate)
    output_cost = compute_cost(usage.output_cost, usage.output_tokens, output_rate)

    total_cost =
      cond do
        not is_nil(usage.total_cost) -> usage.total_cost
        is_number(input_cost) and is_number(output_cost) -> input_cost + output_cost
        true -> nil
      end

    %{usage | input_cost: input_cost, output_cost: output_cost, total_cost: total_cost}
  end

  # Never overwrite an already-populated cost; only fill nil. When the
  # token count is nil OR the per-million rate is nil, leave at nil.
  defp compute_cost(existing, _tokens, _rate) when not is_nil(existing), do: existing

  defp compute_cost(_existing, tokens, rate) when is_integer(tokens) and is_number(rate),
    do: rate * tokens / 1_000_000

  defp compute_cost(_existing, _tokens, _rate), do: nil
end