Last reviewed: November 10, 2026 (Asia/Shanghai).

Direct answer

Build a CometAPI Elixir Req client by constructing a server-owned request, posting a small JSON message, and returning a tagged result only after validating the response. Disable automatic redirects and retries explicitly. Decode external JSON with string keys, then accept the ordinary completed-text case your application actually supports.

The CometAPI chat reference defines the upstream endpoint and envelope. Req 0.7.5 provides the HTTP request pipeline; Jason 1.4.5 provides JSON decoding. The example uses these maintained references for a non-streaming integration, without assuming that every model accepts the same optional parameters.

Who this is for

This tutorial is for Elixir developers adding a text-generation adapter to a backend or worker. You need an installed Elixir/Erlang toolchain and Mix. Keep credentials on the server and choose the model through trusted configuration. A Phoenix application can call the adapter from an authorized operation, but authentication, job ownership, admission limits, and browser behavior remain application responsibilities.

The code and acceptance tests are reference implementations checked against public documentation. They were not compiled or executed during preparation, and no authenticated generation or deployment was performed. Run the offline tests in your own toolchain before making a bounded live request.

Key takeaways

  • Give application callers a prompt method, rather than an unrestricted list of Req options.
  • Separate request failure, HTTP failure, JSON failure, and an unaccepted completion.
  • Keep incoming JSON keys as strings; creating atoms from external keys adds avoidable risk.
  • Use Req.Test to inspect the outbound request without network access or a live key.
  • Treat the example’s body-size check as a parsing limit after download.
  • Keep retries and user-visible error handling outside the minimal adapter.

Create the Mix project

Create a project with mix new comet_demo, enter its directory, and replace the generated deps/0 list in mix.exs with:

defp deps do
  [
    {:req, "== 0.7.5"},
    {:jason, "== 1.4.5"},
    {:plug, "~> 1.0", only: :test}
  ]
end

Run mix deps.get and retain mix.lock with the application. These Req and Jason pins identify the documentation versions used here; they are not a recommendation to ignore later security or compatibility updates. Plug supports the offline adapter used by the tests and is limited to the test environment in this example.

Before configuring a real model, consult the CometAPI model catalog reference . It documents a public catalog with model IDs, capability metadata, and endpoint hints. Confirm a text model’s chat support and your account access separately. A catalog entry alone does not prove that an authenticated request will succeed.

Implement the request and acceptance boundary

Create lib/comet_demo/client.ex:

defmodule CometDemo.Client do
  @endpoint "https://api.cometapi.com/v1/chat/completions"
  @max_prompt_bytes 8_000
  @max_parse_bytes 1_048_576
  defstruct [:request, :model]

  def new(key, model) when is_binary(key) and is_binary(model) do
    if config_string?(key) and config_string?(model) do
      request =
        Req.new(
          url: @endpoint,
          auth: {:bearer, key},
          headers: [{"accept", "application/json"}],
          retry: false,
          redirect: false,
          raw: true,
          compressed: false,
          connect_options: [timeout: 5_000],
          receive_timeout: 30_000,
          request_timeout: 30_000
        )

      {:ok, %__MODULE__{request: request, model: model}}
    else
      {:error, :configuration}
    end
  end

  def new(_, _), do: {:error, :configuration}

  def complete(%__MODULE__{} = client, prompt) when is_binary(prompt) do
    if String.valid?(prompt) and byte_size(prompt) <= @max_prompt_bytes and
         String.trim(prompt) != "" do
      body = %{
        "model" => client.model,
        "messages" => [%{"role" => "user", "content" => prompt}],
        "stream" => false
      }

      case Req.post(client.request, json: body) do
        {:ok, response} -> read_response(response)
        {:error, _reason} -> {:error, :request_failed}
      end
    else
      {:error, :prompt}
    end
  end

  def complete(%__MODULE__{}, _), do: {:error, :prompt}

  defp config_string?(value) do
    String.valid?(value) and String.trim(value) != "" and
      String.trim(value) == value and
      not String.contains?(value, ["\r", "\n"])
  end

  defp read_response(%Req.Response{status: status}) when status != 200,
    do: {:error, {:http, status}}

  defp read_response(%Req.Response{} = response) do
    cond do
      not json_media?(response) ->
        {:error, :media_type}

      not is_binary(response.body) ->
        {:error, :body_type}

      byte_size(response.body) > @max_parse_bytes ->
        {:error, :parse_limit}

      true ->
        case Jason.decode(response.body, keys: :strings, strings: :copy) do
          {:ok, data} -> accept_text(data)
          {:error, _reason} -> {:error, :json}
        end
    end
  end

  defp json_media?(response) do
    case Req.Response.get_header(response, "content-type") do
      [value] ->
        media = value |> String.split(";", parts: 2) |> hd()
        String.downcase(String.trim(media)) == "application/json"

      _ ->
        false
    end
  end

  defp accept_text(%{
         "object" => "chat.completion",
         "choices" => [
           %{
             "finish_reason" => "stop",
             "message" => %{"role" => "assistant", "content" => text} = message
           }
         ]
       }) when is_binary(text) do
    if Map.get(message, "refusal") == nil and
         Map.get(message, "tool_calls", []) in [nil, []] and
         String.trim(text) != "" do
      {:ok, text}
    else
      {:error, :outcome}
    end
  end

  defp accept_text(_), do: {:error, :outcome}
end

The constructor fixes the destination and transport policy. complete/2 constructs the JSON object itself, so a prompt cannot replace the model, bearer credential, URL, or retry setting. The limits are local examples: 8,000 UTF-8 bytes are not 8,000 model tokens. Add model-specific output controls only after verifying that model’s current contract.

Req’s documented json: option encodes the request body. raw: true disables automatic response decoding and decompression, allowing the client to inspect status and media type before calling Jason. The adapter does not request compression. An unexpectedly compressed response will not be transparently decoded by this code.

The JSON pattern deliberately requires one choice, an assistant message, a stop finish reason, and nonblank text. Missing or null refusal is accepted; a refusal value, nonempty tool calls, truncation, or a different envelope returns :outcome. This is a narrow local policy derived from the chat reference, not the complete range of valid CometAPI responses. Applications supporting tools or partial output need their own result types.

Jason documents string keys as the default and warns that decoding arbitrary keys to atoms can exhaust an atom table because those atoms are not garbage collected. Keep string-key matching here. strings: :copy also avoids retaining references to the original response binary through decoded strings, at the cost of additional copying. It does not impose a memory ceiling.

When you intend to make one live request, load the key through your secret mechanism and open iex -S mix. Then use the module explicitly:

alias CometDemo.Client

with {:ok, client} <- Client.new(
       System.fetch_env!("COMETAPI_KEY"),
       System.fetch_env!("COMETAPI_MODEL")
     ) do
  Client.complete(client, "Explain idempotency in one sentence.")
end

IEx displays the expression’s result, so use non-sensitive input and an appropriate local terminal. In an application, send accepted text to its authorized consumer. Keep the constructed client out of interactive inspection and routine logs.

The error tuples carry fixed categories and an HTTP status, never the raw Req exception or Jason error. Those original values may contain response data. Do not inspect the client struct in logs: its request contains the credential. Add only approved correlation and usage fields when the surrounding application needs operational evidence.

Contract details to verify

The Req options reference documents a default retry policy limited to safe methods for its listed transient conditions. This tutorial still sets retry: false to make the generation adapter’s policy explicit. It does not claim that an ordinary POST would otherwise receive every default retry. A surrounding task runner can still repeat the whole method, so audit that layer separately.

BoundaryExample policyWhat it establishes
Redirectredirect: falseA redirect is returned to the status check instead of being followed automatically.
Retryretry: falseReq does not apply its automatic retry step to this request.
ConnectionFive-second connection timeoutA local connection setting, not a provider latency guarantee.
Receive/requestThirty-second transport settingsExplicit adapter allowances; application queueing needs its own deadline.
ParsingOne-mebibyte check before JasonOversized buffered bodies are not parsed.
AcceptanceOne ordinary completed text choiceOther valid API outcomes remain application errors for this method.

The body is already buffered when read_response/1 runs. Consequently, @max_parse_bytes limits parsing eligibility, not bytes downloaded or peak memory. Error bodies are also received before their HTTP status is classified. For untrusted large responses, implement and test a bounded chunk collector before enabling traffic. The site’s Node.js response-byte guide explains that separate transport boundary.

A timeout or local rejection cannot establish whether remote generation finished or usage was recorded. Keep automatic retries disabled until the application defines how to handle uncertain outcomes. For another transport’s explicit policies and response types, compare the Rust reqwest client .

Inspect the request without calling the network

Req.Test routes a request through a Plug stub when plug: {Req.Test, name} is set. Its expectation and verification helpers can check the number of adapter invocations. Create test/comet_demo/client_test.exs; keep the generated ExUnit.start() in test/test_helper.exs.

defmodule CometDemo.ClientTest do
  use ExUnit.Case, async: true
  alias CometDemo.Client
  setup {Req.Test, :verify_on_exit!}

  setup do
    {:ok, client} = Client.new("test-key", "fixture-model")
    request = Req.merge(client.request, plug: {Req.Test, __MODULE__})
    {:ok, client: %{client | request: request}}
  end

  defp completion do
    %{
      "object" => "chat.completion",
      "choices" => [%{
        "finish_reason" => "stop",
        "message" => %{
          "role" => "assistant", "content" => "Hello", "refusal" => nil
        }
      }]
    }
  end

  test "sends the configured JSON request once", %{client: client} do
    Req.Test.expect(__MODULE__, fn conn ->
      assert conn.method == "POST"
      assert conn.host == "api.cometapi.com"
      assert conn.request_path == "/v1/chat/completions"
      assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer test-key"]
      assert Jason.decode!(Req.Test.raw_body(conn)) == %{
        "model" => "fixture-model",
        "messages" => [%{"role" => "user", "content" => "Hi"}],
        "stream" => false
      }
      Req.Test.json(conn, completion())
    end)

    assert Client.complete(client, "Hi") == {:ok, "Hello"}
  end

  test "rejects bad input before the adapter", %{client: client} do
    Req.Test.stub(__MODULE__, fn _conn ->
      flunk("invalid prompt reached the HTTP adapter")
    end)

    assert Client.complete(client, "  ") == {:error, :prompt}
    assert Client.complete(client, String.duplicate("x", 8_001)) == {:error, :prompt}
    assert Client.complete(client, <<255>>) == {:error, :prompt}
  end

  test "does not retry a simulated transport timeout", %{client: client} do
    Req.Test.expect(__MODULE__, fn conn ->
      Req.Test.transport_error(conn, :timeout)
    end)

    assert Client.complete(client, "Hi") == {:error, :request_failed}
  end
end

Run mix test with no real key configured. The credential and model above are synthetic. The tests exercise request construction, zero dispatch for invalid input, and the adapter-level retry setting. They do not exercise DNS, TLS, an actual socket timeout, account permissions, billing, or model quality.

Req.Test uses process ownership for stubs. These tests call the client in the test process and can run asynchronously. If production code moves the call into a separately spawned process, follow the documented allowance rules, including Req.Test.allow/3 where necessary. A missing stub is a test ownership problem to resolve before considering live traffic.

Extend the suite with these fixtures before adopting the adapter:

FixtureRequired result
302 with a Location header{:error, {:http, 302}}; no second adapter invocation
429 or 500 containing arbitrary textHTTP error tuple; no automatic repeat or body in the returned error
200 with HTML media type{:error, :media_type}
JSON media type with malformed JSON{:error, :json}
Buffered body one byte over the parsing ceiling{:error, :parse_limit}
Empty choices, wrong role, refusal, tools, or length{:error, :outcome}
Unexpected additional response metadataAccepted only if the consumed fields still satisfy the policy

These are acceptance requirements, not test results reported by this article.

Failure modes

A successful HTTP response becomes a successful answer automatically. Preserve the envelope and outcome checks. Syntactically valid JSON can contain a refusal, a tool request, or no useful text.

A caller can merge arbitrary Req options. Keep the client construction inside trusted application code. The test-only merge selects a local adapter; it is not an options passthrough for a public endpoint.

The parsing ceiling is described as a download limit. It runs after buffering. Add a bounded collector and transport tests if the deployment requires an inbound byte limit.

A GenServer or task retries after any error tuple. The request’s retry setting does not control its caller. Retain ownership and attempt evidence when deciding whether a new generation is appropriate.

Raw exceptions or the request struct enter logs. Use the fixed error tuple and approved metadata. Neither the fake-key test nor tuple-returning functions make unrestricted logging safe.

Sources checked

Five primary public documentation pages were fetched on November 10, 2026 UTC and Asia/Shanghai. They support a current evergreen integration task; this article makes no recent-release or measured-performance claim.

  • CometAPI: Create a chat completion — endpoint, bearer-authenticated request, message fields, completed-text envelope, and alternate finish reasons.
  • CometAPI: List available models — public catalog, model IDs, capability metadata, and endpoint hints.
  • Req 0.7.5: Req — JSON request encoding, response decoding controls, retry and redirect options, timeouts, and tagged request results.
  • Req 0.7.5: Req.Test — offline Plug adapter, raw request body, expectations, verification, transport-error simulation, and process allowances.
  • Jason 1.4.5: Jason — tuple-returning decoding, string keys, atom risks, and copied-string decoding.

FAQ

Why disable Req’s automatic JSON decoding?

It keeps HTTP status and media-type handling ahead of application JSON parsing. The client can classify an unexpected representation without treating it as a normal completion.

Does {:ok, text} mean the answer is accurate?

It means the response satisfies this adapter’s structural acceptance policy. Evaluate factual correctness and suitability separately before using generated text in a consequential workflow.

Can this method read a stream?

It sends stream: false and expects one JSON completion. Server-sent events need a separate incremental parser and completion policy.

Reader next step

Run and extend the offline suite first. Then load the key through your secret mechanism, configure a verified chat model, construct the client with Client.new/2, and call Client.complete/2 once with short, non-sensitive input when you intend to make a paid request. Keep the accepted text as application output and retain only approved operational evidence. Resolve a contract mismatch before adding retries, additional parameters, or production traffic.