Short answer: compare OpenAI, Claude, and Gemini text classification APIs by migrating one tenant cohort at a time, keeping exactly one path authorized to write CRM actions while every candidate returns metered, validated JSON in shadow.
That is the simple backend design I would trust for comparing OpenAI, Claude, and Gemini. A global bake-off hides the constraint that matters here: a sales-call summarizer needs per-tenant cost visibility across Europe and US processing paths, including the temporary cost of migration. Switching a model name in config is easy. Proving which tenant paid for both paths, and preventing the shadow from creating duplicate CRM work, is the real job.
The unit of change is a tenant cohort, not the whole app.
The domain output should stay boring. A reviewed call summary can produce schedule_security_review
, update_integration_requirement
, assign_account_owner
, or no action. Those values form a closed vocabulary. Unknown tags and malformed JSON are rejected before any comparison, because two adapters returning equally invalid objects have not demonstrated useful agreement.
A cohort combines tenant IDs, an approved region policy, a schema version, and a migration state. In shadow
, the incumbent path remains authoritative and the candidate receives the same minimized summary. Both results are validated, but only the incumbent result may reach the CRM. In candidate
, authority flips for that cohort while the old path can remain a temporary shadow. In stable
, dual execution stops.
This is migration, not permanent double processing.
Picture one summary that supports both schedule_security_review
and update_integration_requirement
. During shadowing, the authoritative adapter returns both tags and its result reaches the normal idempotent CRM writer. The candidate returns one tag, so the comparison event records a disagreement for review, but it has no capability to update the CRM. Production usage and migration usage retain the same tenant ID under different workload classes. An engineer can therefore inspect action quality, regional eligibility, and the temporary migration load without reconstructing ownership from two provider dashboards. If the candidate later becomes authoritative for that cohort, the permission moves with the cohort state; no prompt flag and no adapter-specific branch grants write access.
Per-tenant cost visibility has to survive every state. Record production usage against the tenant and record shadow usage against the same tenant plus a migration
workload class. That split answers two different questions without smearing them together: what did the customer-facing workflow consume, and what did the provider change consume? The adapter supplies reported usage; an application-owned, versioned rate configuration converts it into the accounting unit used by the ledger. No provider price belongs in the routing code.
Europe or US placement is also cohort policy, not prompt text. The router selects only adapters approved for that cohort before it sends the minimized summary. I'm not sure a generic code sample can prove legal compliance for a healthtech company; contracts, data classification, and the actual processing path settle that. A cohort record merely makes the engineering decision explicit enough to inspect and test.
If the source is audio, keep transcription outside this migration. The open-source Whisper project describes a general-purpose speech-recognition model. Treat speech recognition as its own measured stage, then migrate classification against fixed summaries. Otherwise, a changed transcript can look like a changed tagger, and nobody can tell which stage moved the CRM action.
The smallest implementation needs a domain validator, two generic classifier ports, and a sink for metering and comparison events. Provider request shapes stay inside adapters. No SDK leaks into the decision rule.
type ActionTag =
| "schedule_security_review"
| "update_integration_requirement"
| "assign_account_owner";
type RegionPolicy = "eu" | "us";
type MigrationState = "shadow" | "candidate" | "stable";
type WorkloadClass = "production" | "migration";
type Classification = {
tags: ActionTag[];
evidence: string[];
};
type AdapterResult = {
adapterId: string;
modelId: string;
value: unknown;
usage: { inputUnits: number; outputUnits: number };
};
type Classifier = {
classify(input: {
summary: string;
allowedTags: readonly ActionTag[];
region: RegionPolicy;
}): Promise<AdapterResult>;
};
type MeterEvent = {
tenantId: string;
requestId: string;
workload: WorkloadClass;
adapterId: string;
modelId: string;
inputUnits: number;
outputUnits: number;
};
type ComparisonEvent = {
tenantId: string;
requestId: string;
schemaVersion: "crm-action.v1";
primary: Classification;
shadow: Classification;
};
type EventSink = {
meter(event: MeterEvent): Promise<void>;
compare(event: ComparisonEvent): Promise<void>;
};
const allowedTags = [
"schedule_security_review",
"update_integration_requirement",
"assign_account_owner",
] as const;
function parseClassification(value: unknown): Classification {
if (typeof value !== "object" || value === null) {
throw new Error("CLASSIFICATION_NOT_OBJECT");
}
const candidate = value as { tags?: unknown; evidence?: unknown };
if (!Array.isArray(candidate.tags) || !Array.isArray(candidate.evidence)) {
throw new Error("CLASSIFICATION_SHAPE_INVALID");
}
const tags = candidate.tags.filter(
(tag): tag is ActionTag =>
typeof tag === "string" && allowedTags.includes(tag as ActionTag),
);
if (tags.length !== candidate.tags.length) {
throw new Error("CLASSIFICATION_TAG_UNKNOWN");
}
if (!candidate.evidence.every((item) => typeof item === "string")) {
throw new Error("CLASSIFICATION_EVIDENCE_INVALID");
}
return { tags, evidence: candidate.evidence as string[] };
}
async function runAdapter(
classifier: Classifier,
sink: EventSink,
input: {
tenantId: string;
requestId: string;
summary: string;
region: RegionPolicy;
workload: WorkloadClass;
},
): Promise<Classification> {
const result = await classifier.classify({
summary: input.summary,
allowedTags,
region: input.region,
});
await sink.meter({
tenantId: input.tenantId,
requestId: input.requestId,
workload: input.workload,
adapterId: result.adapterId,
modelId: result.modelId,
inputUnits: result.usage.inputUnits,
outputUnits: result.usage.outputUnits,
});
return parseClassification(result.value);
}
async function classifyDuringMigration(
primary: Classifier,
shadow: Classifier | null,
sink: EventSink,
input: {
tenantId: string;
requestId: string;
summary: string;
region: RegionPolicy;
state: MigrationState;
},
): Promise<Classification> {
const primaryResult = await runAdapter(primary, sink, {
...input,
workload: "production",
});
if (input.state !== "stable" && shadow !== null) {
const shadowResult = await runAdapter(shadow, sink, {
...input,
workload: "migration",
});
await sink.compare({
tenantId: input.tenantId,
requestId: input.requestId,
schemaVersion: "crm-action.v1",
primary: primaryResult,
shadow: shadowResult,
});
}
return primaryResult;
}
The return value is always the authoritative result. The shadow has no CRM handle, which is stronger than asking every call site to remember a dryRun
flag. Once the cohort flips, the caller supplies the candidate as primary
; the function itself does not care which company built the adapter.
Metering happens before parsing so malformed output does not disappear from the usage record. A production sink should make requestId
idempotent per adapter and workload class. The example leaves queue retries and storage transactions outside the function because their boundary depends on the host app, but their invariant is clear: a retried shadow may add a metering event, while it still cannot mutate the CRM.
There is no money calculation in the snippet. Good. Usage normalization and rate versions change independently of classification, and hard-coding either inside the adapter turns a migration into a billing-code release.
OpenAI, Anthropic's Claude, and Google's Gemini should enter the same migration harness as adapters, not as three different application architectures. Freeze a de-identified corpus of call summaries and expected CRM actions first. Include no-action summaries, two plausible actions, missing context, quoted customer instructions, long product names, and requests outside the allowed vocabulary. Freeze the corpus hash, reviewed labels, prompt version, schema version, adapter version, region policy, and requested model identifier for each run.
Run the offline corpus before live shadow traffic. JSON parse success is only the first gate. Next measure exact schema validity, per-tag precision and recall, evidence support, and disagreement with the current authoritative path. Keep results per action because frequent account-owner assignments can hide weak security-review tagging. Then attach reported input and output usage to the tenant and workload class used by the harness.
Live shadowing answers a different question: does the candidate behave acceptably on the real mix of summaries, queue timing, tenant policies, and regional paths? It must not create labels for training without review, and agreement with the incumbent is not ground truth. Sample disagreements for human review. A candidate can be correct when the incumbent is wrong, or both can be wrong in the same way.
| Migration signal | What it answers | Cutover role |
|---|---|---|
| Schema validity | Can the adapter honor the closed JSON contract? | Hard gate |
| Reviewed tag quality | Does it choose supported CRM actions? | Hard gate by action |
| Evidence review | Is each action grounded in the summary? | Hard gate for automation |
| Shadow disagreement | Where will behavior change for this cohort? | Review sample |
| Tenant usage | What did production and migration consume? | Capacity and accounting input |
| Region policy | Was an approved path selected? | Eligibility gate |
Don't average an eligibility failure into a score. An unapproved regional path, an unknown tag, or unsupported evidence is not compensated by lower usage elsewhere. Compare cost and latency only among candidates that pass the application contract.
The OpenAI embeddings guide describes embeddings as numerical vector representations useful for tasks including classification. For a stable tag vocabulary with enough reviewed examples, embeddings plus a conventional or nearest-neighbor classifier deserve a separate migration candidate. Let the application assemble JSON deterministically. Measure that design on the same corpus, but don't pretend generated evidence and vector classification are identical outputs.
Move a small reviewed cohort from shadow
to candidate
, then watch action quality, usage attribution, queue delay, and CRM idempotency before expanding. The cohort record should identify who approved the transition and which corpus, schema, prompt, and adapter versions supported it. Rolling back means changing cohort authority, not reverting an unrelated application release.
At larger volume, run shadow calls from a bounded queue. Production work gets its own concurrency pool so a comparison cannot consume every classifier slot. Tenant IDs belong in access-controlled logs or analytical storage rather than unbounded metric labels; metrics can use bounded dimensions such as adapter, model identifier, region, schema version, migration state, and outcome.
Stop dual execution after the observation window. Leaving it on forever doubles the conceptual surface, muddies operational ownership, and makes migration spend look like a permanent product requirement. Keep the frozen corpus as a blocking suite for later prompt, schema, or adapter changes, and retain a smaller reviewed shadow sample only when there is a specific drift question to answer.
Short window. Clear owner.
Shadow migration is not suitable when policy forbids sending the same summary through two processing paths. Use an offline, approved corpus and a clean cohort cutover instead. It is also a bad fit when the old and new systems do not share an honest domain contract; keep provider-specific workflows explicit rather than stuffing every distinction into optional JSON fields.
Stick with deterministic rules or a conventional classifier when labels are stable, reviewed examples are plentiful, and generated evidence adds no useful signal. Use an embeddings-based classifier when it wins on the actual corpus and operational constraints. A generative API should earn the extra validation and review machinery rather than receive it by default.
The catch is temporary duplicate usage. Shadow calls create real work, so a migration needs an explicit budget and a stop condition. Tiny single-tenant tools may be better served by an offline corpus and one scheduled cutover. Multi-tenant healthtech backends gain more from cohorts because region policy, usage, and rollback can remain tenant-scoped.
No product name settles this choice. The defensible API is the candidate that passes the same JSON tagging contract, reviewed action tests, regional eligibility, and per-tenant migration accounting, then survives a reversible cutover without gaining a second path to the CRM.