lib/openagents/shadow_programs/open_ai.ex

main at 58e6347eeb72 · 3 KB

defmodule OpenAgents.ShadowPrograms.OpenAI do
  @moduledoc "OpenResponses 2026-04-24 structured-output adapter for shadow programs."

  @behaviour OpenAgents.ShadowPrograms.Provider

  @endpoint "https://api.openai.com/v1/responses"
  @maximum_response_bytes 65_536

  def id, do: "openai.responses.shadow"

  @impl true
  def evaluate(artifact, signature, input, timeout_ms) do
    with {:ok, api_key} <- fetch_api_key(),
         {:ok, response} <- post(api_key, request_payload(artifact, signature, input), timeout_ms),
         {:ok, output} <- extract_output(response.body) do
      {:ok,
       %{
         output: output,
         response_id: response.body["id"],
         usage: normalize_usage(response.body["usage"])
       }}
    end
  end

  @doc false
  def request_payload(artifact, signature, input) do
    prompt =
      artifact.document["prompt_ir"]["blocks"]
      |> Enum.map_join("\n\n", & &1["content"])

    %{
      model: artifact.document["model"]["model"],
      instructions: prompt,
      input: [%{role: "user", content: Jason.encode!(input)}],
      text: %{
        format: %{
          type: "json_schema",
          name: schema_name(signature.id),
          strict: true,
          schema: signature.output_schema
        }
      },
      max_output_tokens: artifact.document["decoding"]["max_output_tokens"],
      store: false,
      stream: false
    }
  end

  defp fetch_api_key do
    case OpenAgents.RuntimeConfig.fetch_secret(:openai_api_key) do
      {:ok, key} -> {:ok, key}
      {:error, :not_configured} -> {:error, :missing_api_key}
    end
  end

  defp post(api_key, payload, timeout_ms) do
    case Req.post(@endpoint,
           auth: {:bearer, api_key},
           json: payload,
           receive_timeout: timeout_ms,
           retry: false
         ) do
      {:ok, %Req.Response{status: status} = response} when status in 200..299 -> {:ok, response}
      {:ok, %Req.Response{status: status}} -> {:error, {:http_status, status}}
      {:error, %Req.TransportError{reason: :timeout}} -> {:error, :timed_out}
      {:error, _error} -> {:error, :provider_failed}
    end
  end

  defp extract_output(%{"id" => id, "output" => output})
       when is_binary(id) and is_list(output) do
    texts =
      for %{"type" => "message", "content" => content} <- output,
          %{"type" => "output_text", "text" => text} <- content,
          is_binary(text),
          do: text

    case texts do
      [text] when byte_size(text) <= @maximum_response_bytes ->
        case Jason.decode(text) do
          {:ok, decoded} when is_map(decoded) -> {:ok, decoded}
          _invalid -> {:error, :malformed_output}
        end

      _invalid ->
        {:error, :malformed_output}
    end
  end

  defp extract_output(_body), do: {:error, :malformed_response}

  defp normalize_usage(usage) when is_map(usage) do
    usage
    |> Map.take(~w(input_tokens output_tokens total_tokens))
    |> Enum.filter(fn {_key, value} -> is_integer(value) and value >= 0 end)
    |> Map.new()
  end

  defp normalize_usage(_usage), do: %{}

  defp schema_name(signature_id), do: String.replace(signature_id, ~r/[^a-zA-Z0-9_-]/, "_")
end