test/openagents/providers/open_ai/stream_decoder_test.exs

main at 58e6347eeb72 · 7 KB

defmodule OpenAgents.Providers.OpenAI.StreamDecoderTest do
  use ExUnit.Case, async: true

  alias OpenAgents.Providers.OpenAI.StreamDecoder
  alias OpenAgents.Providers.ProviderEvent.ToolCall

  test "decodes fragmented lifecycle, text, tool, usage, and completion events" do
    stream =
      frame(%{"type" => "response.created", "response" => %{"id" => "resp_123"}}) <>
        frame(%{"type" => "response.output_text.delta", "delta" => "Hel"}) <>
        frame(%{"type" => "response.output_text.delta", "delta" => "lo"}) <>
        frame(%{
          "type" => "response.output_item.done",
          "item" => %{
            "type" => "function_call",
            "id" => "item_1",
            "call_id" => "call_1",
            "name" => "recall_messages",
            "arguments" => ~s({"query":"One"})
          }
        }) <>
        frame(%{
          "type" => "response.completed",
          "response" => %{
            "id" => "resp_123",
            "usage" => %{
              "input_tokens" => 10,
              "output_tokens" => 4,
              "total_tokens" => 14,
              "input_tokens_details" => %{"cached_tokens" => 2},
              "output_tokens_details" => %{"reasoning_tokens" => 1}
            }
          }
        })

    chunks = for <<chunk::binary-size(7) <- stream>>, do: chunk
    remainder_size = rem(byte_size(stream), 7)

    chunks =
      if remainder_size == 0,
        do: chunks,
        else: chunks ++ [binary_part(stream, byte_size(stream) - remainder_size, remainder_size)]

    assert {:ok, decoder, events} = feed_all(chunks)
    assert {:ok, _decoder, final_events} = StreamDecoder.finish(decoder)

    assert events ++ final_events == [
             {:response_started, "resp_123"},
             {:text_delta, "Hel"},
             {:text_delta, "lo"},
             {:tool_call,
              %ToolCall{
                item_id: "item_1",
                call_id: "call_1",
                name: "recall_messages",
                raw_arguments: ~s({"query":"One"})
              }},
             {:usage,
              %{
                "cache_read_input_tokens" => 2,
                "input_tokens" => 10,
                "output_tokens" => 4,
                "reasoning_output_tokens" => 1,
                "total_tokens" => 14
              }},
             {:response_completed, "resp_123"}
           ]
  end

  test "decodes chat-completions usage and omits cache read tokens when absent" do
    stream =
      frame(%{"type" => "response.created", "response" => %{"id" => "resp_cc"}}) <>
        frame(%{
          "type" => "response.output_text.delta",
          "delta" => "Ok"
        }) <>
        frame(%{
          "type" => "response.completed",
          "response" => %{
            "id" => "resp_cc",
            "usage" => %{
              "prompt_tokens" => 7,
              "completion_tokens" => 2,
              "total_tokens" => 9
            }
          }
        })

    assert {:ok, decoder, events} = feed_all([stream])
    assert {:ok, _decoder, final_events} = StreamDecoder.finish(decoder)

    assert events ++ final_events == [
             {:response_started, "resp_cc"},
             {:text_delta, "Ok"},
             {:usage, %{"input_tokens" => 7, "output_tokens" => 2, "total_tokens" => 9}},
             {:response_completed, "resp_cc"}
           ]
  end

  test "decodes prompt token cache details from chat-completions usage" do
    stream =
      frame(%{"type" => "response.created", "response" => %{"id" => "resp_cache"}}) <>
        frame(%{
          "type" => "response.completed",
          "response" => %{
            "id" => "resp_cache",
            "usage" => %{
              "prompt_tokens" => 12,
              "completion_tokens" => 3,
              "total_tokens" => 15,
              "prompt_tokens_details" => %{"cached_tokens" => 6}
            }
          }
        })

    assert {:ok, decoder, events} = feed_all([stream])
    assert {:ok, _decoder, final_events} = StreamDecoder.finish(decoder)

    assert events ++ final_events == [
             {:response_started, "resp_cache"},
             {:usage,
              %{
                "input_tokens" => 12,
                "output_tokens" => 3,
                "total_tokens" => 15,
                "cache_read_input_tokens" => 6
              }},
             {:response_completed, "resp_cache"}
           ]
  end

  test "decodes reasoning summary and reasoning text deltas as reasoning events" do
    stream =
      frame(%{"type" => "response.created", "response" => %{"id" => "resp_r"}}) <>
        frame(%{"type" => "response.reasoning_summary_text.delta", "delta" => "Weighing "}) <>
        frame(%{"type" => "response.reasoning_text.delta", "delta" => "options."}) <>
        frame(%{"type" => "response.output_text.delta", "delta" => "Done."}) <>
        frame(%{"type" => "response.completed", "response" => %{"id" => "resp_r"}})

    assert {:ok, decoder, events} = feed_all([stream])
    assert {:ok, _decoder, final_events} = StreamDecoder.finish(decoder)

    assert events ++ final_events == [
             {:response_started, "resp_r"},
             {:reasoning_delta, "Weighing "},
             {:reasoning_delta, "options."},
             {:text_delta, "Done."},
             {:response_completed, "resp_r"}
           ]
  end

  test "rejects malformed JSON and a completed response with a changed ID" do
    assert {:error, :invalid_provider_event} =
             StreamDecoder.feed(StreamDecoder.new(), "data: {not-json}\n\n")

    assert {:ok, started, _events} =
             StreamDecoder.feed(
               StreamDecoder.new(),
               frame(%{"type" => "response.created", "response" => %{"id" => "resp_one"}})
             )

    assert {:error, :invalid_provider_event} =
             StreamDecoder.feed(
               started,
               frame(%{"type" => "response.completed", "response" => %{"id" => "resp_two"}})
             )
  end

  test "truncated streams and provider cancellation are typed terminal outcomes" do
    assert {:ok, decoder, [{:response_started, "resp_partial"}]} =
             StreamDecoder.feed(
               StreamDecoder.new(),
               frame(%{"type" => "response.created", "response" => %{"id" => "resp_partial"}})
             )

    assert {:error, :truncated_stream} = StreamDecoder.finish(decoder)

    assert {:ok, cancelled, [:cancelled]} =
             StreamDecoder.feed(StreamDecoder.new(), frame(%{"type" => "response.cancelled"}))

    assert {:ok, _decoder, []} = StreamDecoder.finish(cancelled)
  end

  test "provider failures expose a bounded normalized code rather than raw objects" do
    raw = %{
      "type" => "response.failed",
      "response" => %{
        "id" => "resp_failed",
        "error" => %{
          "code" => "rate_limit_exceeded",
          "message" => "private provider detail"
        }
      }
    }

    assert {:ok, decoder,
            [
              {:response_started, "resp_failed"},
              {:failed, {:provider_failed, "rate_limit_exceeded"}}
            ]} =
             StreamDecoder.feed(StreamDecoder.new(), frame(raw))

    assert {:ok, _decoder, []} = StreamDecoder.finish(decoder)
  end

  defp feed_all(chunks) do
    Enum.reduce_while(chunks, {:ok, StreamDecoder.new(), []}, fn chunk, {:ok, decoder, events} ->
      case StreamDecoder.feed(decoder, chunk) do
        {:ok, next_decoder, next_events} ->
          {:cont, {:ok, next_decoder, events ++ next_events}}

        {:error, reason} ->
          {:halt, {:error, reason}}
      end
    end)
  end

  defp frame(value), do: "data: " <> Jason.encode!(value) <> "\n\n"
end