Skip to main content

lib/billdog_eng/llm.ex

defmodule BilldogEng.Llm do
  @moduledoc """
  LLM observability — capture a model invocation as a trace span.

  Wraps `POST /llm/trace`. The wire payload is camelCase (matching the edge
  function's Zod schema) and authenticates with `X-BillDog-API-Key`.

  Mirrors the Node SDK `llm.ts`.
  """

  alias BilldogEng.Transport

  @doc """
  Record a single LLM trace span.

  Required params (keyword list): `:trace_id`, `:span_id`, `:model`,
  `:input_text`, `:output_text`.

  Optional: `:parent_span_id`, `:prompt_tokens`, `:completion_tokens`,
  `:duration_ms`, `:cost_usd`, `:properties`, `:metadata`, `:timestamp`
  (ISO-8601; defaults to now).
  """
  @spec capture_trace(BilldogEng.t(), keyword()) ::
          {:ok, term()} | {:error, BilldogEng.Error.t()}
  def capture_trace(client, params) do
    body = %{
      api_key: client.api_key,
      traceId: Keyword.fetch!(params, :trace_id),
      spanId: Keyword.fetch!(params, :span_id),
      parentSpanId: Keyword.get(params, :parent_span_id, ""),
      model: Keyword.fetch!(params, :model),
      inputText: Keyword.fetch!(params, :input_text),
      outputText: Keyword.fetch!(params, :output_text),
      promptTokens: Keyword.get(params, :prompt_tokens, 0),
      completionTokens: Keyword.get(params, :completion_tokens, 0),
      durationMs: Keyword.get(params, :duration_ms, 0),
      costUsd: Keyword.get(params, :cost_usd, 0),
      properties: Keyword.get(params, :properties, %{}),
      metadata: Keyword.get(params, :metadata, %{}),
      timestamp: Keyword.get(params, :timestamp) || iso_now()
    }

    Transport.request(client.transport,
      path: "/llm/trace",
      body: body,
      headers: [{"x-billdog-api-key", client.api_key}],
      gzip: false
    )
  end

  defp iso_now do
    DateTime.utc_now() |> DateTime.to_iso8601()
  end
end