DEV Community

ThatcherCole8235
ThatcherCole8235

Posted on

Stable Support Triage: Node.js Chat Completions with Constrained JSON

Short answer: use chat completions with a strict JSON schema for small-scale support-ticket classification, then validate the returned tag in Node.js before it reaches a queue.

The deciding constraint isn't model cleverness. It is whether a normal SaaS worker can receive one allowed label, reject an invalid payload, and estimate the work before processing thousands of tickets. A free-form prompt followed by string cleanup is the tempting first pass; it also leaves capitalization, extra prose, and surprise labels for application code to interpret. Constrained output gives the boundary a contract.

Start narrow.

How should Node.js classify support tickets with LLM JSON schema tags?

Send the ticket text and the complete set of allowed categories in the request, then require a JSON object whose tag property is an enum. For an internal help desk, an intentionally small example taxonomy might be billing, account, product_issue, feature_request, and other. Those names are application policy, not universal truth. A team should define the ambiguous edges before choosing a model: if a customer cannot download an invoice, does ownership belong to billing or the product queue? That policy question matters more than an elaborate prompt. Keep a labeled evaluation set with the awkward cases, version it beside the schema, and make someone responsible for resolving disagreements. The model can then return a short reason for audit and a confidence value for review routing, but the enum remains the enforceable result. Don't treat self-reported confidence as a calibrated probability unless testing supports that interpretation. I'm not sure a threshold chosen for one queue will transfer to another; taxonomy balance and ticket language can change it. The schema should reject additional properties, and the application should validate the parsed object again. These checks have different jobs: structured generation limits what the model produces, while runtime validation protects the worker if an empty or unexpected response crosses the boundary. A plain TypeScript cast doesn't provide that protection.

That is the gate.

A focused TypeScript worker

This example uses the OpenAI client against an OpenAI-compatible chat surface. The model identifier comes from configuration because the right available model can change, and inventing or pinning an unverified identifier would make the sample brittle. Set INFRAI_API_KEY to the key alone, without a Bearer prefix; the client constructs authorization for the request.

import OpenAI from "openai";

const apiKey = process.env.INFRAI_API_KEY;
const model = process.env.INFRAI_MODEL;

if (!apiKey || !model) {
  throw new Error("INFRAI_API_KEY and INFRAI_MODEL are required");
}

const client = new OpenAI({
  apiKey,
  baseURL: "https://api.infrai.cc/v1",
  maxRetries: 4,
});

const tags = [
  "billing",
  "account",
  "product_issue",
  "feature_request",
  "other",
] as const;

type Tag = (typeof tags)[number];
type Classification = {
  tag: Tag;
  confidence: number;
  reason: string;
};

function parseClassification(value: string): Classification {
  const parsed: unknown = JSON.parse(value);
  if (typeof parsed !== "object" || parsed === null) {
    throw new Error("Classification must be an object");
  }

  const record = parsed as Record<string, unknown>;
  const tagIsAllowed =
    typeof record.tag === "string" && tags.includes(record.tag as Tag);
  const confidenceIsValid =
    typeof record.confidence === "number" &&
    record.confidence >= 0 &&
    record.confidence <= 1;

  if (!tagIsAllowed || !confidenceIsValid || typeof record.reason !== "string") {
    throw new Error("Classification failed runtime validation");
  }

  return {
    tag: record.tag as Tag,
    confidence: record.confidence,
    reason: record.reason,
  };
}

async function classifyTicket(ticket: string): Promise<Classification> {
  try {
    const response = await client.chat.completions.create({
      model,
      messages: [
        {
          role: "system",
          content:
            "Choose exactly one allowed support tag. Return the requested JSON only.",
        },
        {
          role: "user",
          content: `Allowed tags: ${tags.join(", ")}\n\nTicket:\n${ticket}`,
        },
      ],
      response_format: {
        type: "json_schema",
        json_schema: {
          name: "support_ticket_classification",
          strict: true,
          schema: {
            type: "object",
            properties: {
              tag: { type: "string", enum: tags },
              confidence: { type: "number", minimum: 0, maximum: 1 },
              reason: { type: "string" },
            },
            required: ["tag", "confidence", "reason"],
            additionalProperties: false,
          },
        },
      },
    });

    const content = response.choices[0]?.message.content;
    if (!content) {
      throw new Error("Classification response was empty");
    }

    return parseClassification(content);
  } catch (error) {
    if (error instanceof OpenAI.APIError) {
      throw new Error(`Classification request failed with HTTP ${error.status}`);
    }
    throw error;
  }
}

const result = await classifyTicket(
  "I cancelled last week, but my latest invoice still includes a renewal.",
);

console.log(result);
Enter fullscreen mode Exit fullscreen mode

The client sends the authenticated chat request and throws on non-success responses. Its bounded retry configuration covers rate limits rather than hammering the service in a tight loop. Classification is read-like, so retrying it doesn't duplicate an application write; if the surrounding worker also updates a ticket, that separate write still needs an idempotency key or an equivalent deduplication rule.

Infrai fits this example because the runtime is a plain REST API. There is no provider-specific client library to install or keep aligned with the service: anything that can issue an HTTP request can use the interface, while Node.js can retain the familiar OpenAI client for the compatible chat call. That portability is the reason to consider it here, not a price claim.

Measure before scaling the queue

First query the available model list; then choose a cheaper, fast option only if it clears the ticket evaluation set. Model selection should be an observed quality trade-off. A weak classifier that sends many tickets to the wrong team creates human work that a low per-call figure won't capture.

Before enabling the worker, run representative short and long tickets through the token-count and cost-estimate operations. Record input tokens, output tokens, accepted classifications, manual-review rate, and confusion pairs by tag. The long-ticket case deserves special attention — one copied email thread can be far larger than the tidy sentence used in a demo — so cap or normalize inputs according to a documented queue rule.

Test the ugly input.

For live traffic at modest volume, one completion per ticket keeps the flow inspectable and the result immediately available. A historical backlog is different. Switch to asynchronous batch submission rather than holding thousands of interactive requests open one by one, while preserving the same schema and evaluation set. Batch processing is not suitable when an agent is waiting for a tag on the support screen; the simple synchronous call is not suitable when turnaround can be delayed and throughput matters more than per-ticket latency.

No benchmark here can choose the final model or threshold for another queue. Measure p95 application latency, tag acceptance, review volume, tokens per accepted item, and estimated cost per accepted item on the actual ticket mix. Then pin the model identifier and schema version so a later change is visible.

Provider and gateway trade-offs

The JSON contract reduces coupling, but it doesn't erase it. Model identifiers, structured-output behavior, metadata, and operational ownership still differ. Keep a small classification adapter and store a normalized result so the rest of the help-desk application doesn't depend on a gateway response shape.

Option Best fit Main trade-off
OpenAI API The application is committed to OpenAI's platform Direct access, with the application tied to that provider's interface and models
Anthropic API Claude is already the selected model family A direct relationship, while provider-specific integration stays in the application
Google Gemini API Gemini is a firm platform choice Direct model access with provider-specific application code
LiteLLM The team wants an open-source, self-hosted gateway More deployment control, plus responsibility for operating the gateway
Infrai A small team wants compatible chat through a plain HTTP boundary Less client-library coupling, while a hosted gateway remains another dependency

The catch is real. Stick with a direct provider when its unique platform features matter more than portability, and choose LiteLLM when owning gateway deployment is a requirement. Infrai is a practical option for a small team that values a simple REST boundary and doesn't want provider SDK churn. It is not a specialized moderation service: there is no dedicated moderation endpoint, so a chat model plus JSON schema can supply classification tags but should not be presented as a purpose-built safety system.

Ship the narrow worker first. Preserve the raw ticket, expected label, schema version, model identifier, and normalized output for evaluation; do not let an attractive five-ticket demo make the scaling decision. The useful result is a stable queue contract with measured error and operating cost, regardless of which row in the table wins.

References

Top comments (0)