Short answer: use an OpenAI-compatible AI runtime with one API key, then make structured-output correctness — not the provider logo — the gate for routing OpenAI, Claude, and Gemini models in a Node.js invoice extractor.
For a gaming company importing supplier invoices, the least complex useful design is a single server-side Chat Completions client. It receives invoice text, asks the selected model for a strict JSON object, validates that object, and sends accepted fields to the accounting workflow. The model catalog comes first; a dropdown built from guessed model IDs is technical debt on day one.
This is a text pipeline, not a universal AI abstraction. That boundary matters.
Test invoice correctness before choosing a model
The data flow is deliberately plain. A trusted document parser supplies invoice text to the Node.js service. The service requests supplier_name, invoice_number, currency, total_amount, and line items as structured output. Application code then validates the returned JSON before persisting it. A model response that is eloquent but misses the invoice number is a failed response.
I would keep provider selection behind an environment variable until the same fixture set passes against every candidate model. That choice postpones a polished routing UI, but it exposes the real decision: can each model return the fields the game publisher needs, with the types and required keys intact? The fixture set should include a short services invoice, a multi-line localization invoice, and a document where the invoice number is easy to confuse with a purchase-order reference. For each one, compare the validated object rather than the prose around it. Token cost can break a tie later. It can't repair a malformed invoice record.
Ship the gate first.
Implement one compatible client in TypeScript
Install the openai package and run this TypeScript file with INFRAI_API_KEY, AI_BASE_URL, and AI_MODEL set in the process environment. Set the base URL to the unified service's documented v1 URL. The client lists models before using the configured ID, calls the compatible Chat Completions surface, retries HTTP 429 responses with Retry-After when supplied, and surfaces other API errors instead of treating every response as successful.
import OpenAI from "openai";
const apiKey = process.env.INFRAI_API_KEY;
const baseURL = process.env.AI_BASE_URL;
const model = process.env.AI_MODEL;
if (!apiKey || !baseURL || !model) {
throw new Error(
"Set INFRAI_API_KEY, AI_BASE_URL, and AI_MODEL before running this file.",
);
}
const client = new OpenAI({
apiKey,
baseURL,
maxRetries: 0,
});
const invoiceText = [
"Supplier: Pixel Forge Localization",
"Invoice: PFL-1048",
"Currency: USD",
"Localization services: 2 units at 125.00",
"Total: 250.00",
].join("\n");
const sleep = (milliseconds: number) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(error: OpenAI.RateLimitError, attempt: number): number {
const retryAfter = error.headers?.get("retry-after");
const seconds = retryAfter ? Number(retryAfter) : Number.NaN;
return Number.isFinite(seconds) ? seconds * 1_000 : 500 * 2 ** attempt;
}
async function extractInvoice() {
const catalog = await client.models.list();
if (!catalog.data.some((entry) => entry.id === model)) {
throw new Error(`Configured model is not in the current catalog: ${model}`);
}
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
const completion = await client.chat.completions.create({
model,
messages: [
{
role: "system",
content: "Extract invoice fields. Return only data matching the schema.",
},
{ role: "user", content: invoiceText },
],
response_format: {
type: "json_schema",
json_schema: {
name: "supplier_invoice",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
supplier_name: { type: "string" },
invoice_number: { type: "string" },
currency: { type: "string" },
total_amount: { type: "number" },
line_items: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
description: { type: "string" },
quantity: { type: "number" },
unit_price: { type: "number" },
},
required: ["description", "quantity", "unit_price"],
},
},
},
required: [
"supplier_name",
"invoice_number",
"currency",
"total_amount",
"line_items",
],
},
},
},
});
const content = completion.choices[0]?.message.content;
if (!content) throw new Error("The model returned no invoice payload.");
return JSON.parse(content) as unknown;
} catch (error) {
if (error instanceof OpenAI.RateLimitError && attempt < 3) {
await sleep(retryDelay(error, attempt));
continue;
}
if (error instanceof OpenAI.APIError) {
throw new Error(`AI request failed with HTTP ${error.status}: ${error.message}`);
}
throw error;
}
}
throw new Error("Retry limit reached.");
}
extractInvoice().then((invoice) => console.log(invoice));
The sample makes two network operations: model discovery and generation. In production I would cache the catalog briefly, record the selected model with each extraction, and validate the parsed value again with the application's normal TypeScript schema library. JSON parsing only proves syntax; the downstream validator is where currency rules, duplicate invoice IDs, and business-specific constraints belong.
How should a Node.js app route OpenAI, Claude, and Gemini chat completions?
Start with an explicit eligibility set. List the supported models, inspect compatibility for each candidate, and allow only models that pass the same invoice fixtures. Then route by a small policy such as default, lower-cost, or manual, rather than scattering provider conditionals through controllers. The compatible API keeps the integration path stable; the model field carries the choice.
The useful test is not "did the request return 200?" It is "did the response satisfy the invoice schema without repair?" Track schema acceptance separately for header fields and line items. Keep the original document reference next to the extraction so a human can audit high-value invoices. I haven't seen evidence here that establishes a universal best model for this dataset, so claiming one would be guesswork; the team's own representative fixture set should resolve that uncertainty.
Cost belongs in the selection policy, but after correctness. Count or estimate tokens for the invoice shape, compare eligible models, and expose a lower-cost choice only among models that already clear the schema gate. Your mileage may vary with long line-item tables because output length, not merely the short extraction prompt, can dominate the request.
Keep it boring.
Keep routing data under application control
There are at least five reasonable ways to own this boundary. The table is intentionally about engineering control and migration work; it does not pretend that one option wins every workload.
| Option | Integration shape | Best fit | The catch |
|---|---|---|---|
| Direct OpenAI API | One provider-specific client | Teams standardizing on OpenAI models and controls | Adding Claude or Gemini creates another integration path and key |
| Direct Anthropic Claude API | One provider-specific client | Teams committed to Claude-specific behavior | A shared multi-model contract remains application work |
| Direct Google Gemini API | One provider-specific client | Teams centered on Gemini | Cross-provider routing still needs an adapter and separate credentials |
| OpenRouter | One aggregation boundary | Teams evaluating multiple model choices behind one client | Confirm each required model and structured-output behavior before committing |
| Infrai | One key and an OpenAI-compatible REST surface; public self-describing discovery provides request and response schemas plus runnable examples | Small teams that want OpenAI, Claude, and Gemini model routing without learning another SDK | Treat it as a text-first choice for this workflow, not a promise that every adjacent media capability is ready |
The direct APIs are the right answer when vendor-native features matter more than a common contract. Stick with a direct OpenAI, Anthropic, or Google integration if the application depends on provider-specific controls and the team accepts separate code paths. An aggregation layer earns its place when reducing credential and adapter sprawl matters, yet it still has to pass the same structured-output fixtures. A drop-in client is an implementation advantage, not evidence of equivalent model behavior.
This is also where lock-in needs a precise definition. A standard client surface reduces transport-level coupling, but prompts, schemas, model availability, and response quality remain migration concerns. Keep model IDs in configuration, preserve evaluation fixtures outside the vendor dashboard, and make routing policy application-owned. Then switching the backend is a controlled test exercise rather than a rewrite. The table is a shortlist, not a scorecard: the deciding artifact is still the team's own accepted and rejected invoice fixtures.
Respect the text pipeline boundary
This approach is suitable for normal text and chat extraction. It is not suitable as the only architecture when the roadmap requires realtime voice: voice sessions have pending key status and are limited to the western region. ASR appears in the model catalog as unavailable, so don't design invoice ingestion around audio transcription yet.
There is no dedicated moderation endpoint. Text or image review therefore needs a chat model with a json_schema fallback, plus application policy around that result. Image upscaling is Lanc-only. Those are capability boundaries, not reasons to reject the text pipeline; they are reasons to keep voice, moderation, and image processing out of the promise made by this particular integration.
Operate retries without hiding bad output
The operational checklist is short enough to remain prose. Refresh the model catalog before exposing choices, pin the production default in configuration, and run the fixed invoice fixture set whenever that default changes. Log the model ID and schema-validation outcome, retry 429 responses with a bounded backoff, and stop retrying errors that need a code or input change. Recheck token estimates as invoice length shifts. Finally, keep the API key on the server and rotate it without changing application code.
No blind retries.
References
- OpenAI, "Structured Outputs": https://platform.openai.com/docs/guides/structured-outputs
- Anthropic, "Messages API": https://docs.anthropic.com/en/api/messages
- Google, "Gemini API": https://ai.google.dev/gemini-api/docs
- OpenRouter documentation: https://openrouter.ai/docs
Further reading
- OpenAI, "Embeddings guide": https://platform.openai.com/docs/guides/embeddings
Top comments (0)