Last reviewed: November 13, 2026 (Asia/Shanghai); sources fetched November 12 UTC.

Direct answer

Build a CometAPI C++ client by serializing an application-owned JSON request, keeping its buffer alive through curl_easy_perform, collecting response bytes through a bounded callback, and validating the completion before returning text. Use C++ destructors to release the easy handle and header list on success and exceptions.

The CometAPI Chat Completions reference documents the HTTPS operation, bearer authentication, messages, and response outcomes. The libcurl tutorial supplies the synchronous transport and resource lifecycle. The nlohmann/json 3.12.0 README supplies JSON construction, serialization, parsing, and type inspection.

The important C++ detail is ownership. CURLOPT_POSTFIELDS retains a pointer to your data. Passing request.dump().c_str() directly leaves that pointer referring to a destroyed temporary after the statement. Store the serialized string first, then preserve it until the transfer finishes.

Who this is for

Use this walkthrough for a C++17 command-line utility or blocking worker that needs a small, non-streaming text integration. It assumes exceptions are enabled and uses libcurl with HTTPS support plus nlohmann/json 3.12.0. The CURLOPT_PROTOCOLS_STR option requires libcurl 7.85.0 or later; use a supported, patched build in your environment.

The example owns one easy handle per call. A high-volume service should design connection reuse and concurrency separately. If you are comparing ownership models across languages, the Rust client walkthrough uses a reusable asynchronous client, while the Java client walkthrough bounds collection through a subscriber.

Key takeaways

  • Keep the serialized POST string, response state, and headers alive throughout the transfer.
  • Release the easy handle before destroying objects referenced by its options.
  • Treat callback input as counted bytes; it is not a null-terminated string.
  • Catch allocation failures inside the C callback and report them after libcurl returns.
  • Apply the body ceiling to error responses as well as successful responses.
  • Check HTTP status, media type, JSON representation, and completion outcome separately.

Prepare the project and model configuration

Install libcurl development files and nlohmann/json through your project’s reviewed dependency process. The JSON library is header-only. A POSIX environment with curl-config and the JSON header on its include path can compile the example with:

c++ -std=c++17 -Wall -Wextra -Wpedantic comet.cpp \
  $(curl-config --cflags --libs) -o comet-demo

The libcurl tutorial documents curl-config for the include and link flags. If your JSON header is elsewhere, add its include directory. Keep the selected dependency versions in your build records. Windows and cross-compilation projects should use their toolchain’s corresponding dependency and link configuration.

Select an exact, current model ID that supports Chat Completions. The CometAPI model reference documents public GET /api/models discovery without an authorization header, including IDs, capability tags, and endpoint hints. Verify your account’s access separately. Set COMETAPI_MODEL in trusted process configuration and inject COMETAPI_KEY through your secret mechanism.

Own the buffers and C resources

Create comet.cpp and start with these definitions:

#include <curl/curl.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <iostream>
#include <limits>
#include <memory>
#include <stdexcept>
#include <string>

using json = nlohmann::json;
constexpr const char* ENDPOINT =
    "https://api.cometapi.com/v1/chat/completions";

struct Fault : std::runtime_error {
    using std::runtime_error::runtime_error;
};

struct CurlRuntime {
    CurlRuntime() {
        if (curl_global_init(CURL_GLOBAL_ALL) != CURLE_OK)
            throw Fault("curl_initialization");
    }
    ~CurlRuntime() { curl_global_cleanup(); }
    CurlRuntime(const CurlRuntime&) = delete;
    CurlRuntime& operator=(const CurlRuntime&) = delete;
};

struct Headers {
    curl_slist* value = nullptr;
    void add(const std::string& line) {
        auto* next = curl_slist_append(value, line.c_str());
        if (!next) throw Fault("header_allocation");
        value = next;
    }
    ~Headers() { curl_slist_free_all(value); }
    Headers() = default;
    Headers(const Headers&) = delete;
    Headers& operator=(const Headers&) = delete;
};

struct EasyDeleter {
    void operator()(CURL* handle) const noexcept {
        curl_easy_cleanup(handle);
    }
};

struct Body {
    std::string bytes;
    std::size_t limit = 1'048'576;
    bool too_large = false;
    bool allocation_failed = false;
};

std::size_t collect(char* data, std::size_t size,
                    std::size_t count, void* context) noexcept {
    auto& body = *static_cast<Body*>(context);
    if (size != 0 && count > std::numeric_limits<std::size_t>::max() / size) {
        body.too_large = true;
        return 0;
    }
    const auto length = size * count;
    if (length == 0) return 0;
    if (body.bytes.size() > body.limit ||
        length > body.limit - body.bytes.size()) {
        body.too_large = true;
        return 0;
    }
    try {
        body.bytes.append(data, length);
    } catch (...) {
        body.allocation_failed = true;
        return 0;
    }
    return length;
}

CurlRuntime initializes libcurl once for this program’s lifetime. Its destructor runs after the call’s local resources have been released. In an existing application, place this responsibility in the application’s startup and shutdown ownership; do not create competing global cleanup scopes around active workers.

The header wrapper preserves its original list if curl_slist_append fails. The easy handle uses a unique_ptr deleter. Both owners prohibit accidental copying where applicable, avoiding two objects freeing the same C resource.

The write-callback reference says incoming data is not null-terminated and that returning a different byte count aborts the transfer with CURLE_WRITE_ERROR. collect checks the cumulative size before appending and catches exceptions inside the callback. The error flags preserve the local cause without letting a C++ exception escape through libcurl.

The one-mebibyte ceiling bounds the length of the collected string. It does not bound all process memory: transport buffers already exist, string capacity can exceed length, and JSON parsing allocates additional objects. The code also adds no parser nesting-depth limit. Add a reviewed depth policy for hostile or deeply nested inputs and size concurrency independently.

Serialize, send, and accept a text completion

Append these functions to the same file:

bool blank(const std::string& text) {
    return std::all_of(text.begin(), text.end(), [](unsigned char c) {
        return std::isspace(c) != 0;
    });
}

bool json_media(const char* value) {
    if (!value) return false;
    std::string media(value);
    media = media.substr(0, media.find(';'));
    const auto first = media.find_first_not_of(" \t\r\n");
    if (first == std::string::npos) return false;
    const auto last = media.find_last_not_of(" \t\r\n");
    media = media.substr(first, last - first + 1);
    std::transform(media.begin(), media.end(), media.begin(),
                   [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
    return media == "application/json";
}

std::string accepted_text(const std::string& bytes) {
    try {
        const auto root = json::parse(bytes);
        if (!root.is_object() || root.at("object") != "chat.completion")
            throw Fault("completion_outcome");
        const auto& choices = root.at("choices");
        if (!choices.is_array() || choices.size() != 1)
            throw Fault("completion_outcome");
        const auto& choice = choices.at(0);
        const auto& message = choice.at("message");
        if (!choice.is_object() || !message.is_object() ||
            choice.at("finish_reason") != "stop" ||
            message.at("role") != "assistant" ||
            (message.contains("refusal") && !message.at("refusal").is_null()) ||
            (message.contains("tool_calls") && !message.at("tool_calls").is_null()))
            throw Fault("completion_outcome");
        const auto& content = message.at("content");
        if (!content.is_string()) throw Fault("completion_outcome");
        auto text = content.get<std::string>();
        if (blank(text)) throw Fault("completion_outcome");
        return text;
    } catch (const json::exception&) {
        throw Fault("response_json_or_shape");
    }
}

std::string complete(const std::string& key, const std::string& model,
                     const std::string& prompt) {
    if (blank(key) || key.find_first_of("\r\n") != std::string::npos ||
        blank(model) || model.size() > 1'000)
        throw Fault("configuration");
    if (blank(prompt) || prompt.size() > 8'000)
        throw Fault("prompt");

    std::string payload;
    try {
        payload = json{
            {"model", model},
            {"messages", json::array({{{"role", "user"}, {"content", prompt}}})},
            {"stream", false}
        }.dump();
    } catch (const json::exception&) {
        throw Fault("request_json");
    }

    Body body;
    Headers headers;
    headers.add("Authorization: Bearer " + key);
    headers.add("Content-Type: application/json");
    headers.add("Accept: application/json");
    std::unique_ptr<CURL, EasyDeleter> easy(curl_easy_init());
    if (!easy) throw Fault("curl_initialization");

    auto set = [&](CURLoption option, auto value) {
        if (curl_easy_setopt(easy.get(), option, value) != CURLE_OK)
            throw Fault("curl_configuration");
    };
    set(CURLOPT_URL, ENDPOINT);
    set(CURLOPT_PROTOCOLS_STR, "https");
    set(CURLOPT_FOLLOWLOCATION, 0L);
    set(CURLOPT_PROXY, "");
    set(CURLOPT_SSL_VERIFYPEER, 1L);
    set(CURLOPT_SSL_VERIFYHOST, 2L);
    set(CURLOPT_NOSIGNAL, 1L);
    set(CURLOPT_CONNECTTIMEOUT_MS, 5'000L);
    set(CURLOPT_TIMEOUT_MS, 30'000L);
    set(CURLOPT_HTTPHEADER, headers.value);
    set(CURLOPT_POSTFIELDS, payload.c_str());
    set(CURLOPT_POSTFIELDSIZE_LARGE, static_cast<curl_off_t>(payload.size()));
    set(CURLOPT_WRITEFUNCTION, static_cast<curl_write_callback>(collect));
    set(CURLOPT_WRITEDATA, static_cast<void*>(&body));

    const auto result = curl_easy_perform(easy.get());
    if (body.too_large) throw Fault("response_too_large");
    if (body.allocation_failed) throw Fault("response_allocation");
    if (result != CURLE_OK) throw Fault("transport");

    long status = 0;
    char* type = nullptr;
    if (curl_easy_getinfo(easy.get(), CURLINFO_RESPONSE_CODE, &status) != CURLE_OK ||
        curl_easy_getinfo(easy.get(), CURLINFO_CONTENT_TYPE, &type) != CURLE_OK)
        throw Fault("response_metadata");
    if (status != 200) throw Fault("http_" + std::to_string(status));
    if (!json_media(type)) throw Fault("response_media_type");
    return accepted_text(body.bytes);
}

The POSTFIELDS reference explicitly says the data is not copied. Here, payload is a named string whose lifetime extends through the synchronous transfer. Setting POSTFIELDS selects POST; the explicit JSON content-type header replaces the form content type that option otherwise uses. JSON serialization escapes quotes and line breaks rather than interpolating user text into a handwritten document.

Local declaration order also matters. payload, body, and headers are constructed before easy. C++ destroys them in reverse order, so the handle is cleaned up while the referenced objects still exist, including during exception unwinding. The callback receives a pointer to body, and the complete response remains owned by that object until parsing finishes.

The libcurl tutorial explains that the easy interface blocks and that options remain attached to the handle. This sample constructs a fresh handle to keep each call’s borrowed pointers local. Reusing a handle requires replacing or clearing request-specific pointers and preserving their lifetimes on every path. Never use one easy handle simultaneously from multiple threads.

The acceptance function consumes a narrow projection of the documented response. It requires one assistant choice, finish_reason: stop, and nonblank string content. Missing or incompatible fields fail with fixed categories. Any non-null refusal or tool-call field is rejected, including an empty tool_calls array. This last rule is a deliberately strict application choice; supporting more outcomes needs a richer result type and matching fixtures.

get<std::string>() retrieves the decoded answer. Calling dump() on the content value would instead serialize it, adding JSON quoting and escaping. Unknown metadata can remain in the response without being consumed. This function checks decoded structure and outcome; it does not add complete schema validation or duplicate-key rejection.

Contract details to verify

BoundaryExample policyVerification
DestinationFixed HTTPS operation; redirects disabledCapture the requested URI with a fake credential
Request ownershipNamed JSON string survives the transferInspect the POST pointer and bytes during a synthetic exchange
InputNonblank prompt, at most 8,000 string bytesRejection occurs before dispatch
Response ownershipCounted callback writes, one-mebibyte ceilingExact-limit collection succeeds; the next byte is rejected
HTTPRequire status 200 and JSON media type302, 429, and 200 HTML fixtures fail
CompletionOne completed text choice without refusal or toolsUnsupported outcomes never return successful text
DiagnosticsFixed categories and numeric HTTP statusNo key, raw body, prompt, or exception payload enters ordinary errors

The 8,000-byte prompt allowance and 1,000-byte model-ID allowance are local settings. A std::string length is a byte count, not a model-token count. The JSON library expects UTF-8 and can reject invalid string encoding during serialization. Add model-specific generation controls only after checking the selected model’s current parameter contract.

Happy-path operator workflow

Append the offline checks and entry point:

void self_test() {
    auto check = [](bool condition) {
        if (!condition) throw Fault("self_test_failed");
    };
    const auto good = json::parse(R"({"object":"chat.completion","choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"Fixture answer."}}]})");
    check(accepted_text(good.dump()) == "Fixture answer.");
    auto rejected = [&](const json& value) {
        bool failed = false;
        try { (void)accepted_text(value.dump()); }
        catch (const Fault&) { failed = true; }
        check(failed);
    };
    auto changed = good;
    changed["choices"][0]["finish_reason"] = "length";
    rejected(changed);
    changed = good;
    changed["choices"][0]["message"]["content"] = 42;
    rejected(changed);
    changed = good;
    changed["choices"][0]["message"]["refusal"] = "Declined";
    rejected(changed);
    changed = good;
    changed["choices"][0]["message"]["tool_calls"] = json::array();
    rejected(changed);
    rejected(json::array({good}));

    Body body;
    body.limit = 4;
    char four[] = "1234";
    char extra[] = "5";
    check(collect(four, 1, 4, &body) == 4);
    check(collect(extra, 1, 1, &body) == 0);
    check(body.too_large && body.bytes == "1234");
    check(json_media("Application/JSON; charset=utf-8"));
    check(!json_media("text/html"));
}

int main(int argc, char** argv) {
    try {
        if (argc == 2 && std::string(argv[1]) == "--self-test") {
            self_test();
            std::cout << "Offline checks passed.\n";
            return 0;
        }
        if (argc != 1) throw Fault("arguments");
        CurlRuntime runtime;
        const char* key = std::getenv("COMETAPI_KEY");
        const char* model = std::getenv("COMETAPI_MODEL");
        if (!key || !model) throw Fault("configuration");
        const auto answer = complete(key, model,
            "Explain idempotency in one short sentence.");
        std::cout << json{{"text", answer}}.dump() << '\n';
        return 0;
    } catch (const Fault& failure) {
        std::cerr << failure.what() << '\n';
    } catch (const std::exception&) {
        std::cerr << "local_failure\n";
    }
    return 1;
}

Compile first, then run ./comet-demo --self-test. That mode needs no credential and does not initialize a network transfer. It exercises accepted text, truncation, non-string content, refusal, tool-call-field rejection, a wrapped envelope, media-type handling, and the callback copy limit.

When the local checks pass, configure the key privately and select the reviewed model. Running ./comet-demo without the switch initiates one live generation workflow using the harmless built-in prompt and can incur usage. The intended output is a JSON object containing accepted text, which also escapes JSON control characters before they reach a terminal. Treat that text as untrusted data in later consumers.

Retain the compiler and dependency versions, model ID, endpoint family, observation time, status category, and completion verdict. Keep the answer, prompt, bearer value, and request objects out of shared diagnostic records.

Error-path operator workflow

The body callback runs before the final status and media-type checks. Therefore error pages are collected only within the same byte ceiling. An oversized 429 page produces response_too_large before the numeric status branch. An incomplete transfer produces transport; the adapter does not return partial assistant text.

Redirect following is disabled, the destination is fixed, certificate and hostname checks stay enabled, and this demonstration explicitly disables proxy environment use. If an approved proxy is required, configure and test that route deliberately. Do not weaken TLS verification to repair a certificate failure.

The timeout reference defines the maximum duration of the transfer operation; its default of zero provides no transfer timeout. This sample chooses thirty seconds for the transfer and five seconds for connection establishment. Request construction and JSON parsing lie outside that transfer allowance.

There is a build-dependent caveat: the NOSIGNAL reference says that, with the standard synchronous resolver, name resolution cannot time out when NOSIGNAL is enabled. Use a libcurl build with asynchronous DNS when a DNS deadline matters, and verify the installed build’s behavior. The library also documents SIGPIPE considerations for applications using this option; keep process signal handling under the host application’s control.

No application retry loop is included. A timeout or callback abort does not establish that remote processing stopped or that usage was absent. Classify the failure and decide whether another attempt is allowed within the caller’s remaining deadline and attempt budget.

Verification before rollout

The example compiled with GCC 13.3.0, libcurl 8.5.0, and nlohmann/json 3.12.0 during preparation. The included offline checks passed. A separate AddressSanitizer- and UndefinedBehaviorSanitizer-enabled suite intercepted the libcurl option, perform, and metadata functions, then passed synthetic request and response checks without making network requests.

That mocked suite inspected the JSON request with quotes, a newline, and Unicode; the configured endpoint and headers; status 302 and 429; HTML and malformed JSON; valid JSON padded to the exact body ceiling; one byte over the ceiling; and a simulated timeout return. Blank input produced no dispatch. These observations establish local construction and classification, not actual socket, TLS, redirect, or timeout behavior. No authenticated CometAPI call or deployment was performed.

Before rollout, use an isolated HTTP test service with a test-only endpoint override and a dummy credential. Keep that override outside production caller configuration. Verify actual request method and path, redirect non-following, a stalled response body, DNS behavior, and cleanup after an interrupted transfer. Then make one authorized staging request to establish real account/model access. Keep fixture results and live observations distinct.

Failure modes

A POST pointer refers to a temporary. Preserve a named serialized string. CURLOPT_POSTFIELDS does not extend a C++ temporary’s lifetime.

A callback treats data as a C string. Use the provided count. A chunk can lack a terminating zero and may contain arbitrary bytes.

A callback throws through libcurl. Catch append failures inside it, return an error count, and classify the saved flag after the transfer returns.

HTTP 200 is treated as completed text. Keep the JSON and outcome checks. Refusals, tools, and token-limit outcomes need explicit product behavior.

A worker gets a new global cleanup scope per request. Move libcurl’s global ownership to process startup and shutdown before adding concurrency.

The timeout is assumed to cover every operation. Inspect DNS support and account separately for serialization, parsing, work queues, and retries.

FAQ

Why use destructors instead of cleanup calls at every return?

They preserve the release path when validation or allocation throws. Declaration order also keeps the borrowed request and response state alive until handle cleanup finishes.

Does this reuse connections across calls?

Each call creates and destroys its easy handle, so this design provides no cross-call pool. The libcurl tutorial recommends handle reuse when subsequent transfers should reuse connections. Implement that with explicit ownership of the next request’s pointers and a concurrency policy.

Can this consume streaming responses?

The request selects stream: false and expects one JSON document. CometAPI documents streaming as SSE chunks; supporting it requires incremental framing, a completion policy, and its own resource limits.

Does accepted text become safe to execute?

No. The validator selects a response representation and state. Rendering, authorization, business accuracy, and execution permissions require checks in the consuming application.

Sources checked

Eight distinct primary public references returned HTTP 200 on November 12, 2026 UTC:

These maintained technical references support an evergreen integration task. The JSON source is pinned to the tested release. Local size and time settings, error categories, and completion acceptance are application recommendations derived from the documented interfaces.

Reader next step

Compile the complete file, run its offline checks, and exercise real transport behavior with a dummy credential in an isolated test service. Then configure one reviewed model and make a small authorized staging call. Keep the POST buffer, callback state, and cleanup order explicit when adapting the example into a longer-lived application.