DEV Community

LyraP22
LyraP22

Posted on

How to Integrate OpenAI, Claude, and Gemini — One Chat API for SaaS CRM Actions

Short answer: choose an OpenAI-compatible chat completions gateway with a live model list, token counting, and cost comparison, then enforce the CRM action schema in your application. For a junior SaaS build, that is simpler to operate than three vendor SDKs, but the gateway should earn its place through predictable integration and output checks rather than price alone.

The concrete flow is small: a logistics sales-call transcript enters one text-only job, the selected chat model returns structured account actions, and the application validates those actions before writing to the CRM. Keep model selection outside the business logic. That gives a solo developer one place to change the default when model names or availability move, while the validator protects the workflow from a plausible paragraph that isn't valid CRM data.

What must OpenAI-compatible Claude and Gemini outputs preserve for a SaaS CRM?

Preserve the application contract, not a favorite model name. The contract has three parts: a live choice from the model catalog, the standard chat-completions request, and a locally validated CRM action object. Claude and Gemini equivalents can differ in names, context windows, and availability, so a model identifier copied from an old post is a poor configuration strategy.

Structured output correctness is the deciding axis for this logistics workflow. The runnable path comes first because it creates the boundary every candidate must pass.

Mark owners and dates before selecting a model

Do not grade the response as one blob. Account identity and action ownership are release blockers; a weak summary sentence can go to review. Dates need an even harder rule: accept an explicit date, preserve null when none exists, and never turn relative language into a calendar value without application-owned logic. This field-level budget gives the team an observable reason to reject an output instead of arguing about whether it “looks good.”

The first test fixture should contain one explicit customer commitment and one future rep action. That small asymmetry catches a costly class of errors before a broad benchmark does: swapping who owes what. Add fixtures for no follow-up, similar contact names, and ambiguous dates after the narrow path works.

Put the TypeScript boundary in front of the CRM

The first version should accept an existing transcript. Do not wire audio into this path: transcription appears in the broader API shape, but it is not currently serviceable here, and real-time voice sessions are pending and limited to the western region. If transcription is essential now, run a separate speech-recognition component such as Whisper, then pass its text into this boundary. This separation also makes failures legible: speech quality and structured extraction are different things.

Install the OpenAI client, set INFRAI_API_KEY, and choose a model returned by the model-list request rather than guessing its ID.

import OpenAI from "openai";

type ModelRecord = {
  id: string;
  available: boolean;
  capability: string;
  modalities: string[];
};

type ModelList = {
  object: "list";
  capability: string;
  available_only: boolean;
  count: number;
  data: ModelRecord[];
};

type CrmAction = {
  kind: "create_task" | "update_opportunity" | "add_note";
  owner: string;
  due_date: string | null;
  detail: string;
};

type CallSummary = {
  account: string;
  summary: string;
  actions: CrmAction[];
};

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseURL = process.env.AI_GATEWAY_BASE_URL;
if (!baseURL) throw new Error("AI_GATEWAY_BASE_URL is required");

const client = new OpenAI({
  apiKey,
  baseURL,
  maxRetries: 4,
  timeout: 30_000,
});

async function listChatModels(): Promise<ModelRecord[]> {
  const response = await fetch(`${baseURL}/ai/models`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Model listing failed (${response.status}): ${body}`);
  }

  const payload = (await response.json()) as ModelList;
  return payload.data.filter(
    (model) => model.available && model.capability === "chat",
  );
}

function isCallSummary(value: unknown): value is CallSummary {
  if (!value || typeof value !== "object") return false;
  const item = value as Record<string, unknown>;
  if (
    typeof item.account !== "string" ||
    typeof item.summary !== "string" ||
    !Array.isArray(item.actions)
  ) return false;

  return item.actions.every((raw) => {
    if (!raw || typeof raw !== "object") return false;
    const action = raw as Record<string, unknown>;
    return (
      ["create_task", "update_opportunity", "add_note"].includes(
        String(action.kind),
      ) &&
      typeof action.owner === "string" &&
      (typeof action.due_date === "string" || action.due_date === null) &&
      typeof action.detail === "string"
    );
  });
}

async function summarizeCall(
  transcript: string,
  model: string,
): Promise<CallSummary> {
  const completion = await client.chat.completions.create({
    model,
    messages: [
      {
        role: "system",
        content:
          "Extract logistics sales-call facts. Never invent owners or dates. Return JSON matching the supplied schema.",
      },
      { role: "user", content: transcript },
    ],
    response_format: {
      type: "json_schema",
      json_schema: {
        name: "logistics_crm_actions",
        strict: true,
        schema: {
          type: "object",
          additionalProperties: false,
          required: ["account", "summary", "actions"],
          properties: {
            account: { type: "string" },
            summary: { type: "string" },
            actions: {
              type: "array",
              items: {
                type: "object",
                additionalProperties: false,
                required: ["kind", "owner", "due_date", "detail"],
                properties: {
                  kind: {
                    type: "string",
                    enum: ["create_task", "update_opportunity", "add_note"],
                  },
                  owner: { type: "string" },
                  due_date: { type: ["string", "null"] },
                  detail: { type: "string" },
                },
              },
            },
          },
        },
      },
    },
  });

  const content = completion.choices[0]?.message.content;
  if (!content) throw new Error("The model returned no CRM action payload");

  const parsed: unknown = JSON.parse(content);
  if (!isCallSummary(parsed)) {
    throw new Error("The CRM action payload failed local validation");
  }
  return parsed;
}

async function main(): Promise<void> {
  const requestedModel = process.env.CHAT_MODEL;
  const models = await listChatModels();
  const selected = requestedModel
    ? models.find((model) => model.id === requestedModel)
    : models[0];

  if (!selected) {
    throw new Error("Set CHAT_MODEL to an available chat model from /v1/ai/models");
  }

  const transcript = [
    "Account: Northstar Freight.",
    "Maya Chen will send lane volumes by 2026-08-18.",
    "Our rep Luis will schedule a pricing review after the volumes arrive.",
  ].join(" ");

  const result = await summarizeCall(transcript, selected.id);
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}

main().catch((error: unknown) => {
  const message = error instanceof Error ? error.message : String(error);
  process.stderr.write(`${message}\n`);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

Run it with a model selected from the live list:

npm install openai
INFRAI_API_KEY=ifr_your_key AI_GATEWAY_BASE_URL=your_v1_base_url CHAT_MODEL=your_available_model npx tsx crm-actions.ts
Enter fullscreen mode Exit fullscreen mode

The SDK call uses the compatible /v1/chat/completions route and retries HTTP 429 responses with backoff. A write to the CRM is deliberately absent. First inspect and approve the parsed object; when you add the write, give it a stable idempotency key derived from the call ID so a retry cannot create duplicate tasks.

Read valid JSON for unsupported claims

json_schema narrows the output shape, while the local guard prevents unchecked data from crossing into the CRM. Neither proves that the summary is faithful. A valid action can still assign the wrong owner or turn “next quarter” into a fabricated date. For that reason, score semantic fields separately: owner attribution, explicit dates, account identity, and whether every action has direct support in the transcript.

Use the same small evaluation set for every candidate: calls with no follow-up, two contacts with similar names, ambiguous dates, and a promised action that belongs to the customer rather than the sales rep. I'm not sure which model will lead that set for your call mix; the result depends on transcript quality and the fields your CRM requires. The test resolves that uncertainty. Marketing pages don't.

Be strict.

For an early release, I would reject an invalid payload, retain the transcript reference for review, and avoid silently repairing model output. The exact retry policy should come from an evaluation set: one retry can help with transient 429 rate limits, but repeatedly asking a model to reinterpret an ambiguous call can produce different valid-looking answers. That burns tokens without resolving the source ambiguity. A visible review state is cheaper to reason about and safer for sales operations.

Do token counting before sending unusually long transcripts, and use cost comparison when administrators can expose multiple model choices. Those tools reduce billing surprises, but they are guardrails rather than the recommendation itself. The model that wins is the least expensive one that consistently passes the action-level evaluation for your traffic; your mileage may vary as accents, call length, and CRM rules change.

Count dashboards only after the extraction passes

Only now is the vendor comparison useful. Start with interface and operating scope, not a logo.

Option Integration shape Operational trade-off Best fit
OpenAI directly One vendor API and its native model catalog Clear ownership, but it does not provide one key for Claude and Gemini Teams standardizing on OpenAI models
Anthropic directly Separate Claude integration and credentials Direct vendor relationship, with another client path to maintain Teams committed to Claude-specific behavior
Google Gemini directly Separate Gemini integration and credentials Direct access, with its own model configuration to manage Teams already centered on Google's AI platform
OpenRouter Multi-model gateway Reduces credential sprawl; verify its model metadata and output behavior against your eval set Apps that mainly need model routing
Infrai OpenAI-compatible chat plus live model, token, and cost tooling One key and one bill span backend capabilities, and the same conventional client can use the catalog; the broader scope may be unnecessary Small apps that also want to consolidate backend-service administration

The last row's one-key, one-bill design is concrete relief for a solo operator who would otherwise reconcile separate service dashboards. The catch is scope. If the product only needs one model vendor, stick with that vendor directly; if it needs sophisticated AI-specific routing controls, compare a focused gateway such as OpenRouter on those controls before deciding.

Leave the model name outside business logic

Before release, pin a default from the current model list, keep the model ID in configuration, and record the selected model beside each approved result. Exercise the 429 path, empty-content path, malformed JSON path, and locally invalid action path. Confirm that no CRM mutation happens before validation and approval, then make the eventual mutation idempotent. Re-run the fixed transcript set whenever the default model changes.

There is also a product boundary worth stating plainly. This design has no dedicated moderation endpoint; if the app needs text or image review, use a chat model with a JSON Schema contract and validate the result. For high-risk moderation, a specialized service may be the better choice. Image upscaling is limited to Lanczos, which is irrelevant to call summaries but matters if this gateway later grows into a media pipeline.

One API key reduces integration and billing overhead. It does not remove model evaluation, privacy review, or careful CRM writes. Ship the narrow text path, measure correctness with your own calls, and expand only when the next capability has a real job to do.

Sources

Top comments (0)