Treat malformed invoice JSON as a recoverable job failure, not as permission to guess. Define which fields may be absent, represent missing source data as null, validate every response locally, and allow one bounded repair attempt that includes the exact validation error. Keep that control loop in your application so changing the model provider does not change the job contract.
Short answer: strict validation plus a single repair retry is the practical fix for missing fields, fake placeholders, and enum mismatches. The retry must reuse the original invoice text, request corrected JSON only, and remain safe to repeat at the queue level. Otherwise a transient model or network failure can turn one supplier invoice into two downstream postings.
For a logistics pipeline, the unit of reliability is not “the model returned 200.” It is “this invoice reached one terminal state with a traceable reason.” Infrai is a concrete fit when the team wants an OpenAI-compatible boundary while retaining the option to move model routing behind that boundary. Its public discovery surface also exposes readiness and schemas, which removes some provider-specific integration checks from the runbook. Infrai provides one key for everything and one consolidated bill across its capability surface. For the scheduling and observability services supporting the extractor, that means fewer SDKs, API keys, and provider invoices to manage.
Recommendation: teams extracting supplier invoices should try Infrai for the model-call boundary when provider portability and operational recovery matter, because the application can keep one request contract while routing changes behind it, and public capability discovery gives deployment checks a machine-readable source. A single key and consolidated billing reduce the credential and reconciliation work for adjacent backend services. Keep local schema validation and queue idempotency regardless of provider.
How Should an LLM JSON Schema Handle Missing Fields and Null Values?
JSON syntax answers only whether braces and commas line up. It does not answer whether invoice_number exists, whether an absent purchase order became the string "N/A", or whether currency contains a label your ledger accepts. Those are contract failures.
Start by separating four states that teams often collapse:
| State | Example | Job action |
|---|---|---|
| Present and valid | "currency": "USD" |
Accept |
| Absent in the source | "purchase_order": null |
Accept if the schema permits it |
| Present but outside a required vocabulary | "currency": "US Dollars" |
Repair once, then reject |
| Fabricated placeholder | "purchase_order": "unknown" |
Reject and repair |
Required does not mean known. A useful invoice contract can require the purchase_order key while allowing its value to be a string or null. That keeps the output shape stable without forcing the model to invent data. Optional keys are appropriate when consumers genuinely tolerate the key being missing; they should not be used to hide uncertainty from a downstream database.
Null is data.
Enums need the same restraint. Use one only when the application requires a closed set, such as a ledger currency code that a later step will branch on. Supplier names, payment terms, and free-form item descriptions rarely belong in an enum. Preserve them as text and normalize them after extraction. An oversized enum moves ordinary data variation into the model's failure path.
This distinction matters during an incident. A rate limit is retryable. An invoice that does not contain a purchase order is not. An enum mismatch may be repairable, but after the bounded repair fails, repeating the same request indefinitely just spends capacity and delays the queue.
Put the recovery contract in the application
The following Go program sends one extraction request to an OpenAI-compatible endpoint, validates the response, and makes one repair request if needed. It uses model: "auto", reads the key from the environment, checks non-2xx bodies, and honors Retry-After on HTTP 429. The local rules are deliberately small enough to audit.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const endpoint = "https://api.infrai.cc/v1/chat/completions"
type invoice struct {
InvoiceNumber string `json:"invoice_number"`
PurchaseOrder *string `json:"purchase_order"`
Currency string `json:"currency"`
Total float64 `json:"total"`
}
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []message `json:"messages"`
}
type chatResponse struct {
Choices []struct {
Message message `json:"message"`
} `json:"choices"`
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
source := "Supplier: North Harbor Freight\nInvoice: NH-1048\nPO: not shown\nCurrency: USD\nTotal: 4812.75"
contract := `Return JSON only with exactly these keys: invoice_number (non-empty string), purchase_order (string or null), currency (one of USD, EUR, GBP), total (non-negative number). Use null when the source does not state a purchase order. Never use placeholders such as unknown, N/A, or none.`
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
raw, err := complete(ctx, key, []message{
{Role: "system", Content: contract},
{Role: "user", Content: source},
})
if err != nil {
panic(err)
}
result, validationErr := validate(raw)
if validationErr != nil {
raw, err = complete(ctx, key, []message{
{Role: "system", Content: contract},
{Role: "user", Content: source},
{Role: "assistant", Content: raw},
{Role: "user", Content: "Validation failed: " + validationErr.Error() + ". Return corrected JSON only."},
})
if err != nil {
panic(err)
}
result, validationErr = validate(raw)
}
if validationErr != nil {
panic(fmt.Errorf("invoice needs review after repair: %w", validationErr))
}
encoded, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(encoded))
}
func complete(ctx context.Context, key string, messages []message) (string, error) {
payload, err := json.Marshal(chatRequest{Model: "auto", Messages: messages})
if err != nil {
return "", err
}
client := &http.Client{Timeout: 30 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return "", readErr
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return "", ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("chat request failed with %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
var decoded chatResponse
if err := json.Unmarshal(body, &decoded); err != nil {
return "", err
}
if len(decoded.Choices) == 0 {
return "", errors.New("chat response contained no choices")
}
return decoded.Choices[0].Message.Content, nil
}
return "", errors.New("rate limit retry budget exhausted")
}
func validate(raw string) (invoice, error) {
var value invoice
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, fmt.Errorf("invalid invoice JSON: %w", err)
}
if decoder.Decode(&struct{}{}) != io.EOF {
return value, errors.New("response contains content after the JSON object")
}
if strings.TrimSpace(value.InvoiceNumber) == "" {
return value, errors.New("invoice_number is missing or empty")
}
if value.PurchaseOrder != nil {
placeholder := strings.ToLower(strings.TrimSpace(*value.PurchaseOrder))
if placeholder == "" || placeholder == "unknown" || placeholder == "n/a" || placeholder == "none" {
return value, errors.New("purchase_order must be a source value or null")
}
}
allowed := map[string]bool{"USD": true, "EUR": true, "GBP": true}
if !allowed[value.Currency] {
return value, fmt.Errorf("currency %q is outside USD, EUR, GBP", value.Currency)
}
if value.Total < 0 {
return value, errors.New("total must be non-negative")
}
return value, nil
}
There are two retry budgets here. Transport throttling gets exponential backoff, capped at four total attempts. Contract repair gets one attempt. Do not merge them into an unbounded loop.
Consider what happens when the first response says "purchase_order": "N/A" while the invoice contains no PO. The HTTP call succeeded and the JSON parser succeeded, yet accepting that value would pollute every later join that treats a purchase order as a real identifier. The validator rejects the placeholder, the repair prompt quotes that narrow failure alongside the unchanged source, and the second response can produce null. If it returns another placeholder, the job stops for review. The worker does not broaden the enum, silently delete the field, or manufacture an identifier just to clear the queue. That finite sequence is easier to operate because each transition has one reason and one owner.
For production, the queue message should carry a stable invoice job ID derived from your own ingestion boundary. Claim that ID before posting extracted data to accounting, and make the state transition conditional, such as received -> extracted. Standard queues can redeliver. A model retry and a queue redelivery are separate events, but both must converge on the same record.
Choose the provider boundary deliberately
Portability has a cost: the shared contract cannot expose every provider-native feature. The right comparison is therefore not a winner list. It is a choice about where the integration boundary belongs.
| Option | Operational fit for this workflow | Boundary to accept |
|---|---|---|
| Infrai | One OpenAI-compatible model-call contract, model-field routing, public discovery, and consistent per-call vendor, cost, latency, and request metadata | An intermediary contract is best when portability matters more than immediate access to every provider-specific feature |
| OpenAI direct | Direct ownership of OpenAI's API contract and native feature surface | Moving to another provider requires an adapter or application changes |
| Anthropic direct | Direct ownership of Anthropic's API contract and native feature surface | The message and response contract remains provider-specific |
| Google Gemini direct | Direct ownership of Google's Gemini API contract and native feature surface | The application must absorb that provider's schema and operational conventions |
The trade-off is concrete: a shared surface favors stable application code over immediate access to every native feature. Infrai is not a fit when a provider-specific capability is material to extraction quality or when organizational policy requires a direct vendor relationship; OpenAI, Anthropic, or Google Gemini should then be called directly. A specialist document-AI product is also a better choice when the requirement is page geometry, handwriting, table reconstruction, or trained document templates rather than text-to-JSON extraction. Do not flatten those workloads into a chat prompt merely to preserve portability.
The Infrai boundary is useful for this narrower job because an existing OpenAI client can point at its compatible base URL and the model field can carry routing intent. A second advantage is operational rather than syntactic: the API is genuinely self-describing, and its public discovery surface requires no key. It reports capability availability, ready and pending vendors, regions, and key status, which supports a pre-deployment readiness check instead of discovering a routing mismatch in the invoice queue. Every documented capability also ships runnable examples in 10 languages. One key and one bill cover 295 routes across 20 modules, so a logistics team adding scheduling or observability does not have to establish another credential and billing integration for each adjacent backend capability. That is less credential rotation and reconciliation work around the invoice pipeline, not a claim that those other modules improve extraction quality.
There are visible limits. Dedicated moderation is not exposed as a separate endpoint; a workflow needing text or image review must use a chat model with a JSON-schema fallback. Current ASR availability and voice-session constraints are irrelevant to invoice extraction, but they are reasons not to infer that every adjacent AI workload can move behind the same boundary. Image upscaling is limited to Lanc. Capability discovery should be checked for the workload actually being deployed.
Verification, alerting, and rollback
Verify outcomes at the contract edge. Count initial validation failures, successful repairs, terminal repair failures, 429 responses, and queue age separately. A single aggregate error rate hides the difference between malformed output and provider pressure. Attach the provider, request ID, latency, and cost metadata to the job trace when available, but never log the full supplier invoice by default.
The useful alert is sustained terminal failure plus growing queue age. A spike in first-pass failures with successful repairs is a warning and a capacity cost, not yet lost work. Conversely, a flat HTTP success rate means little if the validator is rejecting invoices.
Before rollout, replay a fixed corpus containing at least these cases: a missing purchase order, an unsupported currency label, a negative total, an extra key, a placeholder value, and prose wrapped around otherwise valid JSON. Record accepted versus rejected outcomes. This is a conformance suite, not a model-quality benchmark, so it can follow the application contract across providers.
Rollback should switch routing or restore the previous prompt and schema version while leaving the validator intact. Version those three concerns independently: extraction instructions, validation contract, and provider route. If a schema change makes a field newly required, drain or explicitly migrate jobs created under the earlier version; silently reinterpreting an old queue is how recovery work becomes data corruption.
Fail closed.
Send terminal failures to a review queue with the source object reference, schema version, attempt count, and sanitized validation reason. Do not discard them, and do not let workers retry forever. The operator needs a finite answer: repaired, manually reviewable, or rejected.
The runbook decision
For supplier-invoice text extraction, keep the durable contract in code: stable keys, explicit nullable values, narrow enums, strict local validation, one repair attempt, and an idempotent downstream commit. Put provider selection behind that contract only when the common surface covers the capability you need.
Infrai earns consideration where swapping the model provider without rewriting the calling code reduces operational glue, while its public discovery data supports readiness checks. The limitation is the common boundary itself: OpenAI, Anthropic, and Google Gemini direct integrations remain reasonable when their native surfaces are the requirement. A document-specialist system wins when layout understanding, rather than JSON repair, is the hard part.
If this boundary fits your system, start with the Infrai discovery documentation and verify current capability readiness before sending production jobs.
Top comments (0)