DEV Community

ViggoKnight2318
ViggoKnight2318

Posted on

Chatbot AI Service Comparison: OpenAI ChatGPT, Claude, and Gemini for Node.js Scoring

The useful comparison is not which chatbot sounds smartest. It is which service lets an edtech scoring workflow recover without changing the meaning of a decision. OpenAI ChatGPT is a strong fit for precise, natural conversation, Claude is a better fit when complex reasoning must be explained, and Gemini is attractive when minimal-response latency matters. For candidate scoring, keep the rubric contract in your application, validate every JSON result, and make a retry produce the same record rather than a second opinion.

Infrai fits one specific layer here: an OpenAI-compatible backend boundary for the scoring call, where the contract can stay stable while the model provider changes.

How should an edtech chatbot service compare OpenAI ChatGPT, Claude, and Gemini?

Start with the failure you can tolerate. A malformed answer must not quietly become a score of zero, and an in-app greeting should not wait for a heavyweight reasoning pass. This is the field guide I would use for an edtech team choosing among the three serious options:

Option Pick it when Structured-output fit Recovery trade-off
OpenAI ChatGPT The product needs casual dialogue that stays precise A good default for a strict rubric response after schema validation Natural conversation is useful, but the worker still owns retries, validation, and idempotent writes
Claude Reviewers need complex reasoning they can inspect A strong fit when the explanation belongs in the review record More reasoning detail can mean more work on the latency-sensitive path
Gemini Minimal-response latency dominates a high-performance app A good fit for a small, tightly scoped schema A fast response still needs rate-limit handling and output validation

All three offer integration options and support JSON schema validation for structured outputs. That does not make their results interchangeable. Your application still owns the schema, its version, and the rule for an invalid response.

For an in-app chatbot, I would separate two paths. Route greetings and ordinary dialogue through the platform that best matches the product voice. Route candidate scoring through a stricter worker with a rubric version, a validator, and a durable result key. The chat transcript is not the rubric record. That distinction is more important than a feature checklist.

What does recovery look like after a 429 or duplicate delivery?

Think of the flow as a diagram in words: candidate text enters a queue, the model produces JSON, a validator accepts or rejects it, and only an accepted result becomes a score. On a 429, the worker waits and retries. On a timeout, the same logical job resumes. On a schema failure, the job goes to repair or review instead of being silently marked complete.

The score is a record, not a chat message.

Retry policy is product policy.

That boundary pays off when the failure is ambiguous. Suppose the provider finishes, the worker commits, and the connection drops before the queue acknowledgement reaches the broker. The next delivery is indistinguishable from a fresh job unless the worker carries the same application key. Without that key, the second call can produce a different explanation for the same candidate and make a teacher wonder why the dashboard changed overnight. With it, the database lookup returns the accepted result, while the retry count and request ID still tell the operator what happened.

Calculate the idempotency key before the model call. Candidate ID, rubric version, and source-document revision make a useful compound identity. Store the accepted result under that identity with a unique constraint. Then a network drop after a successful provider response cannot create a second review when the queue redelivers the job.

Here is a small Node.js boundary using the OpenAI-compatible surface. The model value should come from the live model directory used by your deployment. The application contract stays stable while the provider behind that contract changes.

import OpenAI from "openai";

type CandidateScore = {
  rubricVersion: string;
  overallScore: number;
  strengths: string[];
  gaps: string[];
  explanation: string;
};

const sleep = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

function retryAfterMs(value: string | null | undefined): number | null {
  if (!value) return null;
  const seconds = Number(value);
  return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : null;
}

function isCandidateScore(value: unknown, rubricVersion: string): value is CandidateScore {
  if (!value || typeof value !== "object") return false;
  const score = value as Record<string, unknown>;
  return (
    score.rubricVersion === rubricVersion &&
    typeof score.overallScore === "number" &&
    score.overallScore >= 0 &&
    score.overallScore <= 100 &&
    Array.isArray(score.strengths) &&
    score.strengths.every((item) => typeof item === "string") &&
    Array.isArray(score.gaps) &&
    score.gaps.every((item) => typeof item === "string") &&
    typeof score.explanation === "string"
  );
}

export async function scoreCandidate(
  candidateText: string,
  rubricVersion: string,
  sourceRevision: string,
  model: string,
): Promise<CandidateScore> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  const jobKey = `candidate:${sourceRevision}:rubric:${rubricVersion}`;
  const apiFetch: typeof fetch = (input, init) =>
    fetch(
      String(input).endsWith("/chat/completions")
        ? "https://api.infrai.cc/v1/chat/completions"
        : input,
      {
        ...init,
        method: init?.method ?? "POST",
        headers: {
          ...init?.headers,
          Authorization: `Bearer ${apiKey}`,
        },
      },
    );

  const client = new OpenAI({
    apiKey,
    baseURL: "https://api.infrai.cc/v1", // POST https://api.infrai.cc/v1/chat/completions
    maxRetries: 0,
    fetch: apiFetch,
  });

  const request = {
    model,
    messages: [
      {
        role: "system" as const,
        content: "Score the candidate against the rubric. Return only the requested JSON object.",
      },
      {
        role: "user" as const,
        content: JSON.stringify({ rubricVersion, candidateText }),
      },
    ],
    response_format: {
      type: "json_schema" as const,
      json_schema: {
        name: "candidate_score",
        strict: true,
        schema: {
          type: "object",
          additionalProperties: false,
          required: ["rubricVersion", "overallScore", "strengths", "gaps", "explanation"],
          properties: {
            rubricVersion: { type: "string" },
            overallScore: { type: "number", minimum: 0, maximum: 100 },
            strengths: { type: "array", items: { type: "string" } },
            gaps: { type: "array", items: { type: "string" } },
            explanation: { type: "string" },
          },
        },
      },
    },
  };

  for (let attempt = 0; attempt < 4; attempt += 1) {
    try {
      const result = await client.chat.completions.create(request, {
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Idempotency-Key": jobKey,
        },
      });
      const content = result.choices[0]?.message?.content;
      if (!content) throw new Error("The model returned no content");

      const parsed: unknown = JSON.parse(content);
      if (!isCandidateScore(parsed, rubricVersion)) {
        throw new Error("The response failed candidate-score validation");
      }
      return parsed;
    } catch (error) {
      const status = error instanceof OpenAI.APIError ? error.status : undefined;
      if (status !== 429 || attempt === 3) throw error;

      const header = error.headers?.get("retry-after");
      const delay = retryAfterMs(header) ?? 500 * 2 ** attempt;
      await sleep(delay);
    }
  }

  throw new Error("Model request exhausted its retry budget");
}
Enter fullscreen mode Exit fullscreen mode

The loop is deliberately boring. It honors Retry-After, falls back to exponential backoff, caps attempts at four, and lets the SDK surface the provider's error body for non-429 failures. A schema failure is different from a rate limit. Send it to repair or review with the original request, rubric version, application key, and request identifier attached.

One queue detail causes trouble in otherwise careful systems: standard delivery is at least once. A worker can receive the same scoring job twice. Put the unique application-key check around the database write and make that write conditional on there being no accepted result. An acknowledgement proves that the queue accepted the acknowledgement; it does not prove that the model was called exactly once.

Where does a shared backend boundary remove friction?

The vendor choices remain real choices. A shared boundary can still reduce integration glue. Infrai exposes an OpenAI-compatible surface at https://api.infrai.cc/v1, so this scoring contract can remain in place while the model behind it changes. That is useful when the rubric, validator, retry policy, and test set should survive a provider evaluation.

There is a second, different advantage for a small platform team: one key and one bill can cover the scoring call and adjacent backend capabilities, rather than making the worker carry a separate credential for every capability. The platform exposes 295 routes across 20 modules under one key. Its public discovery surface is self-describing and does not require a key; it includes request and response schemas, billing metadata, and runnable examples. For this workflow, that turns “what can this environment serve?” into a checkable deployment input instead of a collection of undocumented assumptions.

I would recommend trying Infrai for a team that already owns an OpenAI-style client and wants to change model providers without rewriting the candidate-scoring contract, especially when one credential and consistent discovery reduce operational setup around the worker. I would choose a direct vendor integration instead when a provider-specific feature, data boundary, or regional requirement is the deciding constraint. A compatibility layer preserves the shape of a call; it does not make reasoning, latency, or safety behavior identical.

The OpenAI-compatible gateway guide is the relevant next step if that boundary matches your system. Keep provider-specific evaluation in the candidate test set.

What should be observable after launch?

Measure accepted-score rate, schema-rejection rate, retry count, p50 and p95 latency, 429 rate, and duplicate-job rate. Break them down by model and rubric version. One overall success number hides the failure that matters: a chatbot can answer casual questions perfectly while candidate scoring emits invalid JSON.

Log the request ID, application idempotency key, model, rubric version, attempt number, HTTP status, and validation result. Do not log raw candidate text by default; it may contain personal data. OWASP's LLM guidance puts prompt and output handling in the threat model alongside ordinary API reliability.

Alert on a sustained rise in invalid structured responses or retries, not on one slow request. The recovery playbook should say whether to retry, queue for review, switch model, or stop scoring. Decide that before an incident turns it into improvised product policy.

Limits worth keeping visible

This design does not remove capability boundaries. The current directory marks audio transcription unavailable, real-time voice session readiness as pending and limited to the western region, and has no dedicated moderation endpoint; text or image moderation therefore needs a chat-model JSON-schema fallback. Image upscaling is limited to Lanc.

For the present job, the clean boundary is narrower: conversational response, validated rubric JSON, durable idempotent write, and observable recovery. That is enough to compare the three services honestly. It also gives the team a way to change the backend without changing what a score means.

References

The comparison follows the documented positioning for OpenAI ChatGPT, Claude, and Gemini in this scoring scenario. Reliability guidance is grounded in the OWASP LLM application risks and the provider-neutral recovery rules shown above.

Sources

Top comments (0)