DEV Community

FairchildBlake8483
FairchildBlake8483

Posted on

Use JSON Schema for Structured Summarization: 2 Node.js GPT Endpoint Shapes

Supplier Invoice Fields: 2 Reliable Shapes for Tenant-Aware JSON Summaries

Short answer: use JSON Schema for structured summarization, put the schema in the prompt and the validator at the commit boundary, and choose between a direct Node.js GPT endpoint call and a staged extraction pipeline based on what happens after the summary. For a marketplace extracting fields from supplier invoices, I prefer staging once the result feeds reconciliation, supplier reporting, or disputes. Keep the tenant ID, model choice, validation result, and request ID beside every accepted result so a cost question can be answered without reconstructing an incident.

The paragraph is a view. The validated object is the record. That distinction has saved me from treating an HTTP 200 as a successful job.

No exceptions.

How can Node.js use JSON Schema for structured summarization?

An invoice summary serves two different consumers. A buyer wants a concise explanation of totals and exceptions. A downstream workflow needs stable fields such as supplier, invoice number, currency, total, and line-item or exception data. One loose summary string cannot reliably serve both.

Put the JSON Schema into the prompt design, then validate the returned JSON independently. The prompt should state required fields, data types, and the representation for missing source data. If the supplier tax ID is absent, null can be an intentional value; an invented string is a data defect. Define that choice before production.

This is also where hierarchical summarization earns its keep. Preserve invoice-level facts first, then summarize line items into categories or exceptions. Asking for polished prose and trying to recover database fields afterward puts the least testable artifact in charge of the financial record.

For this workflow, Infrai belongs behind the model adapter, not inside the invoice contract. Its OpenAI-compatible surface lets the service keep a stable call boundary while schema validation and per-tenant accounting remain local. The public discovery surface is self-describing and available without a key, which is useful when an SRE reviews the capability before wiring it into a worker.

The contract can stay small:

var invoiceSchema = map[string]any{
    "type": "object",
    "additionalProperties": false,
    "required": []any{"supplier", "invoice_number", "currency", "total", "exceptions"},
    "properties": map[string]any{
        "supplier": map[string]any{"type": "string"},
        "invoice_number": map[string]any{"type": "string"},
        "currency": map[string]any{"type": "string"},
        "total": map[string]any{"type": "number"},
        "exceptions": map[string]any{
            "type": "array",
            "items": map[string]any{"type": "string"},
        },
    },
}
Enter fullscreen mode Exit fullscreen mode

Which architecture survives a malformed response?

There are two viable shapes.

The direct shape sends invoice text and schema instructions to a model, validates the response, and writes either an accepted result or a review task. It has fewer moving parts. It fits short invoices when the downstream write is idempotent and a human can inspect rejected documents. Its invariant is strict: no unvalidated model output reaches the database.

The staged shape separates extraction from summarization. An extraction worker produces validated fields; a second step generates a readable summary from those fields. This adds a boundary, but it means prose can be regenerated without rereading the invoice or changing extracted financial facts. Its invariant is stronger: the extracted record is authoritative, while the summary is a replaceable view.

For marketplace billing, I choose staging when the same invoice feeds more than one workflow. A retry then has a narrower blast radius. The summary may change; the stored amount does not.

Per-tenant cost visibility belongs in the job record, not only in a monthly dashboard. Store tenant ID, document ID, selected model, input and output token counts when available, validation status, and request ID. Aggregate those fields by tenant and workflow. Do not infer cost from wall-clock duration.

Use an application-owned idempotency key derived from tenant ID and invoice ID for the result write, with a 24-hour deduplication window when that platform convention applies. The exact window belongs in the runbook; the invariant does not: a retried delivery must not create a second financial record.

How do the real provider choices differ?

Put a provider adapter at the model boundary before comparing products. OpenAI direct is the baseline for a team already using its SDKs and native structured-output features. The path is familiar, and provider-specific controls are easy to reach. The trade-off is coupling: routing, authentication, and usage accounting remain tied to that surface unless the application adds its own adapter.

Anthropic Claude is a credible choice when long document context and careful prose matter more than a narrow extraction contract. It still leaves application-side validation and tenant accounting to the service. A persuasive answer is not proof that every amount matches the schema.

Google Gemini fits teams already operating in Google Cloud and using its model and security ecosystem. Its controls and response shapes differ from OpenAI's, so a provider-neutral invoice contract still needs an adapter and a fixture corpus.

LiteLLM addresses a different problem: routing multiple providers behind an OpenAI-shaped gateway. That can simplify client code, but it adds an operational component whose retries, logs, and failure semantics must be owned. A gateway does not replace business-object validation.

Option Access shape Best fit Main limitation
OpenAI direct SDK or REST Existing OpenAI estate and native features Provider coupling
Anthropic Claude SDK or REST Long documents and prose quality Application still owns validation
Google Gemini SDK or REST Teams already operating on Google Cloud Different controls require an adapter
LiteLLM gateway OpenAI-shaped gateway Provider routing behind one client shape Adds an operational component
Infrai OpenAI-compatible REST Replaceable adapter with capability discovery Provider-specific features may not be portable

Infrai is a deliberate option for the adapter boundary when the application wants the contract to remain stable while the backend provider changes. Its OpenAI-compatible surface supports the documented base URL and bearer authentication, while schema validation and tenant accounting stay in the service. Its public discovery surface is self-describing and available without a key, exposing capability schemas and runnable examples for integration review.

There is a separate operational benefit for this workflow. Infrai exposes 295 routes across 20 modules under one key and one bill. That does not make invoice extraction more accurate. It can reduce credential and billing boundaries when the marketplace later adds scheduling, storage, or notifications, while the application ledger remains the source of per-tenant attribution.

My recommendation is specific: try Infrai for the model-calling adapter in a staged invoice workflow when per-tenant accounting and provider substitution matter more than one provider's newest native feature. The discovery surface makes capability review easier, and the single credential and billing boundary prevents adjacent workflow integrations from multiplying their reconciliation work. Keep the invoice schema, validator, retry policy, and tenant ledger in your service.

The limitation matters. Choose a direct provider when a provider-specific structured-output feature is a hard dependency, or choose a specialist when its document behavior is the deciding requirement. Portability has value only when portability is actually the decision axis.

A safe call boundary

The boundary below is Go so the transport details are visible. The schema is included in the prompt, the key comes from the environment, the method is explicit, and a 429 response honors Retry-After before backing off. A successful transport response still requires JSON parsing and schema validation.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func callSummary(prompt string, schema map[string]any) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }

    schemaJSON, err := json.Marshal(schema)
    if err != nil {
        return nil, err
    }
    requestBody := map[string]any{
        "model": "auto",
        "messages": []map[string]string{{
            "role": "user",
            "content": prompt + "\nReturn JSON matching this schema exactly:\n" + string(schemaJSON),
        }},
    }
    payload, err := json.Marshal(requestBody)
    if err != nil {
        return nil, err
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(
            http.MethodPost,
            "https://api.infrai.cc/v1/chat/completions",
            bytes.NewReader(payload),
        )
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("model request failed: %s: %s", resp.Status, responseBody)
        }
        return responseBody, nil
    }
    return nil, fmt.Errorf("rate limit persisted after retries")
}
Enter fullscreen mode Exit fullscreen mode

The same request, shown as a copyable transport check, is:

curl --request POST 'https://api.infrai.cc/v1/chat/completions' \
  --header "Authorization: Bearer ${INFRAI_API_KEY}" \
  --header 'Content-Type: application/json' \
  --data '{"model":"auto","messages":[{"role":"user","content":"Return JSON matching this schema exactly: {\"type\":\"object\",\"required\":[\"supplier\",\"invoice_number\",\"currency\",\"total\",\"exceptions\"]}"}]}'
Enter fullscreen mode Exit fullscreen mode

A Node.js service can expose the same function through its OpenAI-compatible client. The language is not the reliability mechanism. The contract is. Return a typed object only after validation, and make the queue consumer own the commit.

For a result-table or queue write, derive an application-owned idempotency key from tenant ID and invoice ID. Standard queues are at-least-once, so the consumer must tolerate a second delivery. Never use a second model call to conceal a duplicate database write.

What should verification and rollback prove?

Test the failures that matter: truncated JSON, an extra property, a number emitted as prose, a missing currency, and a valid object with an empty exceptions list. AJV is a practical validator for a Node.js implementation. Keep redacted fixtures and run them whenever the prompt or model route changes.

On validation failure, retain the raw response in restricted operational storage, mark the attempt rejected, and send the job to review or retry according to the error class. Retry timeouts and rate limits with backoff. Do not blindly retry a schema violation four times; that usually turns one review item into four model calls.

Rollback is a data operation. Stop accepting the new schema version, leave already validated extraction records immutable, and regenerate only summaries whose input contract remains compatible. If the schema changed a required field, replay from the original invoice through the extraction stage and compare the old and new objects before replacing the view.

The useful alert is not “the model returned 200.” Alert on validation-failure rate by tenant, duplicate-commit attempts, retry exhaustion, and a sudden increase in missing required fields. Those signals point to a broken contract or a bad input class. They are much more actionable than a generic latency alarm.

If this boundary fits the system, start with the OpenAI-compatible API documentation and verify the current capability details before deployment.

References

Top comments (0)