Last reviewed: November 8, 2026 (UTC); official references checked November 8, 2026 UTC.
Direct answer
Build the client around Node.js fetch, a server-owned endpoint, and a Zod schema that accepts only the Chat Completions result your application can use. Send a POST request to the documented /v1/chat/completions path, keep provider configuration in process-owned settings, apply an AbortSignal deadline, bound the response bytes before JSON.parse, and validate the decoded object before returning assistant text. The CometAPI Chat Completions reference
documents the OpenAI-compatible base URL, bearer authentication, model, messages, the chat.completion response family, and the choices message shape.
This example intentionally supports one non-streaming text result. It rejects a refusal, tool call, missing content, or any finish reason other than stop. Those are application decisions layered on the wire contract. Streaming is a separate Server-Sent Events contract in the same reference, so it needs a different reader and acceptance policy.
Who this is for
Use this pattern in a Node.js service, worker, or command-line smoke test that needs a visible HTTP boundary without hiding transport behavior in an SDK. It assumes a current Node.js runtime with built-in fetch, Web Streams, and AbortSignal. Keep the provider credential and model identifier in a secret manager or protected process environment. Never accept either value from browser input, commit the credential, or include credentials in logs.
The byte ceiling, prompt limit, and 30-second deadline below are local safeguards. They are not CometAPI account limits, pricing rules, or latency guarantees. Choose values from the caller’s budget and expected response size, then keep those settings under code review.
Key takeaways
- Build the request from trusted configuration and a bounded string.
- Use
AbortSignal.timeout()for the whole request and body-read deadline. - Count
Uint8Array.byteLengthbefore decoding, so a large or multilingual body cannot bypass a character-count check. - Check status and media type before parsing JSON, while enforcing the same body limit on error responses.
- Use Zod’s
safeParseto turn an untrusted decoded value into a typed result or a fixed contract error. - Keep streaming, tool execution, retries, and model discovery as separate contracts.
For the byte-reader mechanics in isolation, see Cap CometAPI Response Bytes Before JSON Parsing in Node.js . For a conservative request fixture, see Type a CometAPI Chat Request in TypeScript .
Set up the small client
Install Zod in the Node.js project:
npm install zod
Supply the provider credential through the deployment secret mechanism and set the model option to an exact model ID that you have checked for the Chat Completions endpoint. The model remains configuration: callers submit only a prompt. Keep the URL fixed in the adapter unless your deployment deliberately supplies a reviewed base URL. The adapter receives a server-owned request-header object; no credential value belongs in this example. Its headers option must be a plain object containing the documented authorization header, assembled by your protected configuration loader. Keep this object inside the server process.
Create chat-client.mjs:
import { z } from "zod";
const ENDPOINT = "https://api.cometapi.com/v1/chat/completions";
const MAX_RESPONSE_BYTES = 1024 * 1024;
const MAX_PROMPT_CHARS = 8000;
const REQUEST_TIMEOUT_MS = 30_000;
export class ChatClientError extends Error {
constructor(code, status = undefined) {
super(code);
this.name = "ChatClientError";
this.code = code;
this.status = status;
}
}
const ChatCompletion = z.object({
object: z.literal("chat.completion"),
choices: z.array(z.object({
finish_reason: z.string().nullable().optional(),
message: z.object({
role: z.literal("assistant"),
content: z.string().nullable().optional(),
refusal: z.string().nullable().optional(),
tool_calls: z.array(z.unknown()).optional(),
}).passthrough(),
}).passthrough()).length(1),
}).passthrough();
function isJsonContentType(value) {
const mediaType = (value ?? "").split(";", 1)[0].trim().toLowerCase();
return mediaType === "application/json" ||
(mediaType.startsWith("application/") && mediaType.endsWith("+json"));
}
async function readBodyWithinLimit(response, limit) {
if (!response.body) throw new ChatClientError("missing_response_body");
const reader = response.body.getReader();
const chunks = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!(value instanceof Uint8Array) ||
value.byteLength > limit - total) {
await reader.cancel();
throw new ChatClientError("response_too_large");
}
chunks.push(value);
total += value.byteLength;
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw new ChatClientError("response_invalid_utf8");
}
}
export async function complete(prompt, {
headers,
model,
} = {}) {
if (!headers || typeof headers !== "object" ||
typeof model !== "string" || !model.trim()) {
throw new ChatClientError("missing_configuration");
}
if (typeof prompt !== "string" || !prompt.trim() ||
prompt.length > MAX_PROMPT_CHARS) {
throw new ChatClientError("invalid_prompt");
}
const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
let response;
try {
response = await fetch(ENDPOINT, {
method: "POST",
redirect: "error",
signal,
headers: {
...headers,
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
stream: false,
}),
});
} catch {
if (signal.aborted) {
throw new ChatClientError("client_timeout");
}
throw new ChatClientError("transport_failed");
}
if (!isJsonContentType(response.headers.get("content-type"))) {
await response.body?.cancel();
throw new ChatClientError("unexpected_response_media_type", response.status);
}
let raw;
try {
raw = await readBodyWithinLimit(response, MAX_RESPONSE_BYTES);
} catch (error) {
if (signal.aborted) throw new ChatClientError("client_timeout");
if (error instanceof ChatClientError) throw error;
throw new ChatClientError("response_read_failed");
}
if (!response.ok) {
throw new ChatClientError("upstream_http_error", response.status);
}
let decoded;
try {
decoded = JSON.parse(raw);
} catch {
throw new ChatClientError("response_invalid_json");
}
const checked = ChatCompletion.safeParse(decoded);
if (!checked.success) {
throw new ChatClientError("response_contract_mismatch");
}
const message = checked.data.choices[0].message;
if (checked.data.choices[0].finish_reason !== "stop" ||
message.refusal != null || message.tool_calls != null ||
typeof message.content !== "string" || !message.content.trim()) {
throw new ChatClientError("unaccepted_completion");
}
return message.content;
}
The Node.js globals reference
documents built-in fetch, AbortSignal, and timeout signals. The signal remains active while the response body is read, so a server that sends headers and then stalls still reaches the same local deadline. redirect: "error" keeps an authenticated POST from silently moving to an unexpected host or path.
The Node.js Web Streams reference
documents ReadableStream.getReader(), read(), cancel(), and releaseLock(). The helper owns one reader, counts bytes before copying a chunk, cancels an over-limit response, and releases the lock in finally. It decodes only after all accepted bytes arrive, so a multibyte UTF-8 character split across chunks is handled as one sequence. This ceiling limits the application buffer; it does not promise a process-wide memory cap when several requests run at once.
The CometAPI reference shows that stream: true produces SSE chunks and that streamed chunks can have an empty choices array. This client sends stream: false and therefore expects one JSON document. Do not reuse this parser for a stream or treat a tool-call choice as ordinary text.
Contract details to verify
| Boundary | Client policy | Review evidence |
|---|---|---|
| Request configuration | Fixed endpoint, server-owned headers, and a reviewed model ID | Current chat reference and a captured synthetic request |
| Response size | Count body bytes before decoding; reject the first chunk that exceeds the ceiling | Exact-limit and one-byte-over-limit fixtures |
| Deadline | One abort signal covers both headers and body reads | Separate pre-header and stalled-body fixtures |
| Completion acceptance | One assistant choice, non-empty text, stop, and no refusal or tool call | Accepted text and rejected-outcome fixtures |
| Diagnostics | Fixed local categories and numeric HTTP status | Logs without credentials, prompts, or provider bodies |
The deadline cannot interrupt synchronous decoding, JSON.parse, or schema validation midway through a JavaScript turn. The byte ceiling bounds their input size; it is not a CPU or process-memory guarantee. Keep concurrency within measured capacity as well.
Sources checked
These primary public references were fetched on November 8, 2026 UTC and support the implementation:
- CometAPI: Create a chat completion
— endpoint, bearer authentication,
modelandmessagesfields, non-streaming completion envelope, finish reasons, tool-call fields, and Server-Sent Events behavior. - Node.js: Global objects
— built-in
fetch,AbortSignal, and timeout-signal behavior used by the request wrapper. - Node.js: Web Streams API — response readers, chunk reads, cancellation, and lock release used by the bounded body helper.
- Zod basics
— schema construction and
safeParseused to validate the decoded response value.
These are stable technical references for an evergreen integration task. The local byte ceiling, prompt ceiling, timeout, accepted finish reason, and logging policy remain application choices; tune them for the service and the selected model.
Why validate with Zod
JSON.parse checks syntax only. A syntactically valid object can still have the wrong object marker, no choices, an unexpected role, or a non-string content value. The Zod basics guide
documents schemas and safeParse, which returns a success or failure result without requiring an exception for ordinary validation failure.
The schema above intentionally requires one choice and the chat.completion marker, then the application policy checks finish_reason, refusal, tool calls, and non-empty content. This separation makes the boundary reviewable: Zod checks the response envelope, while the caller decides which completed outcome it can safely display. If the product supports refusals or tools, return a typed result for those cases instead of weakening the plain-text branch.
Do not log checked.error verbatim in a public request path. Validation details can contain paths that reveal response structure and are rarely useful to an end user. Keep a fixed error code and a request ID in routine logs; retain detailed diagnostics only in an access-controlled debugging path.
Call the client from an application boundary
Keep user-facing validation and error mapping outside the transport function. This wrapper receives the headers plain object and model through your application’s protected configuration loader:
import { complete, ChatClientError } from "./chat-client.mjs";
export async function runDemo({ headers, model }) {
try {
const answer = await complete(
"Explain idempotency in one sentence.",
{ headers, model },
);
console.log(JSON.stringify({ event: "chat_success", text_present: true }));
// Pass `answer` to the authorized application consumer.
} catch (error) {
const code = error instanceof ChatClientError
? error.code : "unexpected_client_error";
console.error(JSON.stringify({ event: "chat_failed", code }));
process.exitCode = 1;
}
}
The wrapper emits no prompt, response body, authorization header, or exception message from the provider. If the service needs metrics, add status class, elapsed time, model identifier, response byte count, and a correlation ID according to its data policy. A local timeout says that this caller stopped waiting; it does not prove whether the upstream generation completed or whether a retry is safe.
The fixed endpoint, model configuration, and accepted outcome also make cost and capability decisions explicit. Keep retries outside this function. A timeout or dropped connection can leave the remote result uncertain, while a deterministic schema error will not be repaired by repeating the same request. Add a bounded retry only after the operation has an attempt identity, a total deadline, and a replay policy.
Verify the boundary without live credentials
Use a local fake Response or an HTTP test server to exercise the reader and parser. The minimum matrix is:
| Fixture | Expected result |
|---|---|
Valid chat.completion with one stopped assistant message | Returns the content string. |
| Exact-limit UTF-8 JSON split across chunks | Decodes and validates successfully. |
| Body one byte over the limit | Cancels the reader and returns response_too_large. |
Valid JSON with finish_reason: "length" | Returns unaccepted_completion. |
Refusal or tool_calls present | Returns unaccepted_completion; no text is displayed. |
| HTML or another non-JSON response | Cancels the body and returns unexpected_response_media_type. |
| 429 or 500 with a bounded JSON error body | Returns upstream_http_error with the status class available to metrics. |
| Malformed JSON or a wrong envelope | Returns a fixed parse or contract code. |
| Body stalls after headers | The AbortSignal deadline ends the request. |
Use synthetic authorization values and models in tests. A passing local test proves request construction, body ownership, and parser behavior; it does not prove account access, model availability, billing, network latency, or production uptime. Before a staging smoke test, inspect the current model source and use a harmless prompt through this same function. The code and matrix above were reviewed against the live references; no executed client tests, authenticated request, or deployment are reported here.
Failure modes
response.json() is called before a size check. That reads and parses the complete body first. Keep the byte counter in the stream reader; the adjacent response-byte guide
explains the ordering in more detail.
Characters are counted instead of bytes. JavaScript string length does not measure the bytes received. Count Uint8Array.byteLength before decoding, especially for non-ASCII output.
A 200 response is treated as success. Check content type, JSON syntax, envelope, choice count, finish reason, refusal, tool calls, and text content separately.
A timeout is retried forever. The upstream result may be unknown. Bound attempts and total time, and decide whether replaying the prompt is safe before retrying.
A provider credential is supplied by a browser. Keep credentials and model selection on the server. The browser should call your authenticated application route, which applies its own authorization and spend controls.
Zod is treated as a provider guarantee. It validates the value your process received; it cannot make an upstream response valid or establish model quality.
FAQ
Does this client support streaming?
No. It sends stream: false and parses one JSON completion. Streaming uses SSE events and needs an event parser, stream lifetime limit, accumulated-output limit, and a policy for incomplete or refused output.
Is the one-mebibyte limit a CometAPI limit?
No. It is an illustrative local response-buffer policy. Measure your response classes and concurrency, then configure a limit that fits the service.
Should I accept every choice returned by the API?
Only if the application contract supports it. This example requests one result and accepts one stopped assistant text. Add a separate typed branch for multiple choices, refusals, tool calls, or truncated output.
Can I use the same Zod schema for the Responses endpoint?
No. Chat Completions and Responses are separate endpoint families with different input and output contracts. Create an endpoint-specific schema after reading the Responses reference.
Does AbortSignal.timeout() guarantee that generation stopped upstream?
No. It limits local waiting and cancels the local fetch. Reconciliation and retry policy require their own evidence and rules.
Reader next step
Create the client with synthetic configuration, run the matrix against a local response source, and confirm that no test log contains a credential, full prompt, or provider body. Then select a current model ID, make one authorized staging request with a harmless prompt, and record only the sanitized status, outcome code, and response-byte count. If your application needs streaming or tools, start a separate contract review from the current CometAPI reference instead of extending this plain-text parser implicitly.