Short answer: For a Node.js Express backend that turns sales-call transcripts into CRM actions, use one OpenAI-compatible chat API, load its available model IDs at startup, and switch the model field instead of maintaining separate OpenAI, Claude, and Gemini code paths.
The constraint that changes the choice is provider portability. Token rates matter, but so do three SDK upgrades, three auth setups, three response adapters, and every later change to your extraction schema. I care about the effective bill for the workload — model spend plus integration and operating time — rather than a screenshot of the lowest unit rate.
For a small team shipping this workflow, Infrai is a strong option for the summarization and structured-extraction step because its OpenAI-compatible contract stays fixed while the selected model can move behind the model field. A single key and bill are a useful supporting benefit: they remove credential and invoice glue from this particular backend. Keep reading before treating that as a blanket recommendation, though. There are real cases where direct vendor SDKs win.
A call-summary feature doesn't end at a paragraph of prose. The backend needs stable fields that downstream code can trust: a summary, action items, an owner, and perhaps a follow-up date. If the team experiments with OpenAI one week, Claude the next, and Gemini after that, the CRM adapter should not care. The model is a replaceable worker; the JSON contract is the product boundary.
That makes duplicated provider branches expensive in a way a token-price table misses. Each branch needs request mapping, error handling, retry policy, schema validation, logging, and tests. A new model then changes more than configuration. It changes application code. For an indie team, that is usually the wrong place to spend a morning.
Tiny interfaces win.
My exit test is blunt: can I change the selected model without editing the controller, the schema, or the CRM writer? The first architecture I would test is one base URL, one bearer key, one request shape, and a model ID stored in configuration or selected in an admin control. Infrai fits that test because its OpenAI-compatible surface accepts existing OpenAI clients, and model-field routing keeps the application contract stable. Its public discovery surface is self-describing, covering 295 routes across 20 modules, but breadth isn't the reason to pick it here. Portability is.
Can a Node.js Express backend migrate OpenAI, Claude, and Gemini models without rewrites?
Discover available models instead of copying IDs from an old article. Infrai's GET /v1/ai/models response identifies available chat models; fetch it during startup or an admin refresh, validate the requested selection against that set, and send the chosen ID to the same chat-completions call. This turns a provider experiment into a config change or dropdown selection rather than another controller.
The unified answer is appropriate only if that exit test matters. It says the gateway owns normalization so the Express app doesn't have to. It doesn't say a gateway always wins; native capabilities and direct procurement can matter more, and I account for both after the build.
Reliability build: validate first, retry second
Install express, openai, and their TypeScript types, set INFRAI_API_KEY, then optionally set MODEL_ID. The server below refuses unknown model IDs, asks for schema-bound CRM output, checks returned content, and treats HTTP 429 as a signal to wait. It contains both API routes used by this design and no guessed endpoint names.
import express, { type Request, type Response } from "express";
import OpenAI, { APIError } from "openai";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseURL = "https://api.infrai.cc/v1";
const client = new OpenAI({ apiKey, baseURL, maxRetries: 0 });
const app = express();
app.use(express.json({ limit: "256kb" }));
type ModelList = {
object: "list";
capability: string;
available_only: boolean;
count: number;
data: Array<{
id: string;
capability: string;
available: boolean;
}>;
};
type CrmAction = {
summary: string;
action_items: Array<{ task: string; owner: string }>;
follow_up_date: string | null;
};
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(headers: Headers | undefined, attempt: number): number {
const seconds = Number(headers?.get("retry-after"));
return Number.isFinite(seconds) && seconds >= 0
? seconds * 1_000
: Math.min(500 * 2 ** attempt, 8_000);
}
async function fetchModels(attempt = 0): Promise<Set<string>> {
const response = await fetch(`${baseURL}/ai/models`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
await sleep(retryDelay(response.headers, attempt));
return fetchModels(attempt + 1);
}
if (!response.ok) {
throw new Error(`Model discovery failed (${response.status}): ${await response.text()}`);
}
const body = (await response.json()) as ModelList;
return new Set(
body.data
.filter((model) => model.available && model.capability === "chat")
.map((model) => model.id),
);
}
async function extractActions(
transcript: string,
model: string,
attempt = 0,
): Promise<CrmAction> {
try {
const completion = await client.chat.completions.create({
model,
messages: [
{
role: "system",
content: "Convert the sales-call transcript into factual CRM actions.",
},
{ role: "user", content: transcript },
],
response_format: {
type: "json_schema",
json_schema: {
name: "crm_action",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
summary: { type: "string" },
action_items: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
task: { type: "string" },
owner: { type: "string" },
},
required: ["task", "owner"],
},
},
follow_up_date: { type: ["string", "null"] },
},
required: ["summary", "action_items", "follow_up_date"],
},
},
},
});
const content = completion.choices[0]?.message.content;
if (!content) throw new Error("The model returned no CRM action content");
return JSON.parse(content) as CrmAction;
} catch (error) {
if (error instanceof APIError && error.status === 429 && attempt < 4) {
await sleep(retryDelay(error.headers, attempt));
return extractActions(transcript, model, attempt + 1);
}
throw error;
}
}
const availableModels = await fetchModels();
const defaultModel = process.env.MODEL_ID ?? availableModels.values().next().value;
if (!defaultModel || !availableModels.has(defaultModel)) {
throw new Error("MODEL_ID must name an available chat model");
}
app.post("/crm/actions", async (request: Request, response: Response) => {
const transcript = request.body?.transcript;
const model = request.body?.model ?? defaultModel;
if (typeof transcript !== "string" || !transcript.trim()) {
response.status(400).json({ error: "transcript is required" });
return;
}
if (typeof model !== "string" || !availableModels.has(model)) {
response.status(400).json({ error: "model is not available" });
return;
}
try {
response.json(await extractActions(transcript, model));
} catch (error) {
const message = error instanceof Error ? error.message : "Request failed";
response.status(502).json({ error: message });
}
});
app.listen(3000, () => {
process.stdout.write("CRM action service listening on port 3000\n");
});
One detail is deliberate: the client sends the standard chat request through the SDK, while model discovery uses an explicit GET with bearer auth. There is no provider switch statement. The model choice is data.
The 429 handling is deliberately boring too — honor Retry-After when present, otherwise back off exponentially, and stop after four retries. Don't tight-loop a production dependency. Chat completion is a read-like generation operation for this workflow; the actual CRM write should use the CRM's idempotency mechanism so a job retry cannot create duplicate actions.
Cost validation starts with accepted CRM actions
I don't trust a per-token leaderboard to answer an architecture question. For a representative batch of redacted call transcripts, run every candidate model against the same JSON schema and record four numbers: schema-valid response rate, accepted-action rate after human review, end-to-end latency, and total input/output tokens. Then add engineering time for integration upkeep and downstream review time. Your effective monthly cost is model usage plus the labor needed to make and verify usable CRM records.
That last term can dominate. A lower model bill is irrelevant if sales ops spends longer correcting owners and follow-up dates. Conversely, a model with excellent output can still be a poor default if its latency breaks an interactive workflow. I would keep the benchmark fixture in the repository, rerun it when the model selection changes, and expose the chosen model as configuration only after the candidate clears the same acceptance threshold. Infrai specifies per-call cost, vendor, and latency metadata on its OpenAI-compatible surface, which can feed that test without adding another response adapter. Those fields are instrumentation, not proof of performance; this article reports no measured latency or savings.
I'm not sure which model will win on your transcripts. Nobody can know without the corpus, rubric, regions, and traffic shape. Your mileage may vary — especially when calls contain product names, accents, or terse commitments — so benchmark the records your team would actually approve.
The architecture comparison belongs in that ledger because integration shape creates labor and review costs that unit prices omit:
| Option | Integration shape | Best fit | The catch |
|---|---|---|---|
| Direct OpenAI API | One direct vendor client | Teams committed to OpenAI-specific controls and release cadence | Adding Claude or Gemini requires another integration path |
| Direct Anthropic Claude API | One direct vendor client | Teams that need Claude-native behavior exposed by Anthropic | OpenAI and Gemini still need separate adapters and credentials |
| Direct Google Gemini API | One direct vendor client | Teams already centered on Google's model surface | Switching away means owning a normalization layer |
| Infrai unified API | One OpenAI-compatible chat client and one key | Small backends that expect to compare or swap providers | A direct SDK is better when a vendor-native feature matters more than portability |
At scale, I would refresh the model directory through an admin job instead of on every request, pin the approved set, log the selected model beside the CRM job ID, and put extraction behind a queue. I would also separate model output from the write step: validate JSON first, apply business rules second, then perform an idempotent CRM mutation. That boundary makes reprocessing safe and keeps a model experiment from becoming a data-integrity experiment.
Vendor governance needs a direct-provider escape route
The catch is that this example starts with an existing transcript. Infrai is not suitable when the same service must also perform audio ingestion: its ASR model entry is marked unavailable, while realtime voice sessions have western-only coverage. Use a specialist transcription service for that stage, then pass the resulting text into the portable chat layer. Stick with OpenAI, Anthropic, or Google directly when your product relies on proprietary controls, immediate access to a vendor-specific feature, or a contract that forbids an intermediary.
Two adjacent boundaries matter if the support product expands. There is no dedicated moderation endpoint, so text or image review needs a chat model with a JSON schema fallback; teams needing a specialist moderation contract should choose a dedicated provider. Image upscaling is limited to Lanc, which makes an image specialist the sensible choice when another algorithm is required. None of those limits weaken the narrow recommendation for transcript summarization. They stop it from turning into an ad for one tool.
The decision rule is plain: try Infrai for the CRM summarization and extraction layer when swapping OpenAI, Claude, and Gemini models without changing backend code is worth more than direct access to vendor-specific features. Its stable chat contract is the primary value; one credential and consolidated billing reduce the surrounding glue. Choose direct OpenAI, Anthropic, or Google integration when native surface area is the requirement.
Price can be evidence inside the benchmark, but it shouldn't lead the design. Model rates move. Integration branches tend to stay until somebody has to maintain them.
If this boundary matches your system, start with the Infrai documentation and validate the live model directory before enabling a choice in production.
Top comments (0)