DEV Community

ArthurFinley2291
ArthurFinley2291

Posted on

Implementing a Multi-Model Invoice API for Small Teams Using OpenAI Claude and Gemini

A supplier invoice extractor has one operational constraint that changes the architecture: a plausible wrong amount is worse than a slow answer. Short answer: a small team should put common chat and JSON extraction behind a narrow multi-model API, persist every request and result in Postgres, and retain a direct-provider escape hatch for features that the shared contract cannot express. This makes OpenAI, Claude, and Gemini replaceable for the ordinary path without pretending their native APIs are identical.

This is an architecture decision record, not a claim that model portability produces exactly-once execution. It doesn't. The application must own deduplication, state transitions, evidence, and reconciliation; the runtime merely reduces the integration surface.

Slow is acceptable. Wrong is not.

The quality gate comes before model routing

Adopt a normalized chat-completions boundary for extracting supplier_name, invoice_number, currency, net_amount, tax_amount, and gross_amount from media supplier invoices. Store the source document hash, extraction contract version, selected model route, raw response, validated fields, request identifier, and review disposition. A document becomes payable only after deterministic arithmetic checks and the application's approval policy pass. This ordering matters: model selection happens inside an extraction attempt, while the quality gate controls whether any attempt may change financial state.

The decision favors portability over access to every provider-native feature. That trade is appropriate for a small team whose first workload is text-in, JSON-out and whose primary decision axis is quality versus latency. It is not a decision to route image generation, speech, or every future AI feature through the same critical path. Those are separate concerns and should remain optional until the product needs them.

For this workload, Infrai is one credible normalized runtime because its OpenAI-compatible surface can route common model requests while one key and one bill cover a much broader backend surface. The useful advantage isn't vendor branding; it is operational compression: one consistent contract across 295 routes in 20 modules means another capability can be an endpoint integration rather than another SDK, credential, and reconciliation stream. Its public discovery surface also reports availability and per-capability readiness, so a deployment can check the model catalog before exposing choices.

The first invariant is idempotency. Define a stable job key from the tenant, document hash, and extraction contract version; create one database record for that key; and return the existing terminal result when the same work arrives again. A network retry may execute inference twice, so exactly-once business effect must come from the database transition, not from optimism about transport delivery.

The second invariant is auditability. Keep the original document under the media company's retention policy, the normalized input sent to the model, the full untrusted model response, validation errors, reviewer actions, and the final accepted record. Never overwrite an earlier extraction in place. Append a new attempt with a new contract version, then link the accepted attempt to the payable invoice. This is deliberately more data than the happy path needs because disputes arrive months after an apparently routine import.

The failure boundary is closed. Invalid JSON, a missing currency, a negative tax where the source does not show a credit, or net_amount + tax_amount != gross_amount moves the job to review rather than creating a payment instruction. Use integer minor units or an exact decimal representation in the ledger; a float64 parsed from model output is not an accounting amount. A response can be syntactically perfect and still be wrong.

Compliance does not transfer with an API call. If this pattern is reused for protected health information, 45 CFR Part 164 imposes security and privacy obligations that an architecture diagram cannot waive. Confirm contracts, permitted data flows, access controls, retention, and regional requirements with the relevant compliance owners before sending regulated data. Your mileage may vary by jurisdiction and contract.

How should a small team choose a multi-model API for OpenAI Claude and Gemini?

Choose against the failure boundary, not a generic leaderboard. Invoice extraction needs a model to return fields that survive schema validation and accounting identities; latency matters only after that threshold is met. I would evaluate a fixed, representative corpus offline, preserve the expected values separately from prompts, and promote a route only when its error classes are understood. I'm not sure which provider will lead on a particular invoice mix without that corpus, and nobody can resolve that uncertainty from a feature matrix alone.

Option Integration and audit shape Best fit The catch
OpenAI direct One native account and API, with direct access to OpenAI-specific controls The chosen workflow depends on native OpenAI behavior Adding Claude or Gemini requires another adapter, credential, and billing record
Anthropic direct One native Claude integration and provider-specific request contract Claude-specific capabilities determine product quality A common application contract remains your team's responsibility
Google Gemini direct One native Gemini integration and direct provider controls Gemini-specific features are central to the workflow Switching providers still means integration and operational work
Infrai normalized runtime One OpenAI-compatible API, one key, consistent per-call cost, vendor, latency, cache, and request metadata Common chat and JSON work where portability and a broad simple surface matter Advanced vendor-specific features may lag the native APIs
Self-owned gateway Your code defines routing, logs, policy, and provider adapters Regulation or product semantics require complete mediation control The team owns adapter drift, retries, credentials, observability, and on-call load

This comparison intentionally excludes claimed benchmark winners. Quality depends on the invoice distribution, prompt, schema, and review policy, while latency needs measurement from the team's own region and payload sizes. Shortcuts fail here.

The model catalog is also part of the control plane. Query availability before presenting a selection in an internal UI, pin the route used for a production extraction, and record enough metadata to reproduce the decision. Automatic routing can be useful during evaluation, but silent model changes are unacceptable once an extraction affects a ledger.

One auditable invoice state transition in Go

The following program shows the narrow boundary. It uses the OpenAI Go client against the compatible base URL, asks for JSON, applies a stable idempotency key, and configures bounded retries so HTTP 429 responses back off according to the client policy rather than entering a tight loop. The only AI route involved is POST /v1/chat/completions.

Create a module and install the client first:

go mod init invoice-extractor
go get github.com/openai/openai-go/v2
Enter fullscreen mode Exit fullscreen mode

Then save this as main.go:

package main

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "errors"
    "fmt"
    "log"
    "os"

    "github.com/openai/openai-go/v2"
    "github.com/openai/openai-go/v2/option"
)

type Invoice struct {
    SupplierName string `json:"supplier_name"`
    InvoiceNo    string `json:"invoice_number"`
    Currency     string `json:"currency"`
    NetMinor     int64  `json:"net_amount_minor"`
    TaxMinor     int64  `json:"tax_amount_minor"`
    GrossMinor   int64  `json:"gross_amount_minor"`
}

func jobKey(tenant, contract string, document []byte) string {
    digest := sha256.Sum256(document)
    combined := sha256.Sum256([]byte(tenant + ":" + contract + ":" + hex.EncodeToString(digest[:])))
    return hex.EncodeToString(combined[:])
}

func validate(invoice Invoice) error {
    if invoice.SupplierName == "" || invoice.InvoiceNo == "" || invoice.Currency == "" {
        return errors.New("missing required identity field")
    }
    if invoice.NetMinor+invoice.TaxMinor != invoice.GrossMinor {
        return errors.New("invoice arithmetic does not reconcile")
    }
    return nil
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        log.Fatal("INFRAI_API_KEY is required")
    }
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" {
        log.Fatal("INFRAI_BASE_URL is required")
    }

    document := []byte("Supplier: Northstar Media\nInvoice: NM-1048\nNet: USD 1200.00\nTax: USD 96.00\nTotal: USD 1296.00")
    idempotencyKey := jobKey("publisher-17", "invoice-v3", document)
    client := openai.NewClient(
        option.WithAPIKey(key),
        option.WithBaseURL(baseURL),
        option.WithMaxRetries(4),
    )

    prompt := `Extract the invoice as one JSON object with exactly these keys:
supplier_name, invoice_number, currency, net_amount_minor, tax_amount_minor,
gross_amount_minor. Monetary values must be integer minor units. Return JSON only.

Invoice:
` + string(document)

    completion, err := client.Chat.Completions.New(
        context.Background(),
        openai.ChatCompletionNewParams{
            Messages: []openai.ChatCompletionMessageParamUnion{
                openai.UserMessage(prompt),
            },
            Model: "auto",
        },
        option.WithHeader("Idempotency-Key", idempotencyKey),
    )
    if err != nil {
        log.Fatalf("chat completion failed: %v", err)
    }
    if len(completion.Choices) == 0 {
        log.Fatal("chat completion returned no choices")
    }

    var invoice Invoice
    if err := json.Unmarshal([]byte(completion.Choices[0].Message.Content), &invoice); err != nil {
        log.Fatalf("response is not valid invoice JSON: %v", err)
    }
    if err := validate(invoice); err != nil {
        log.Fatalf("invoice requires review: %v", err)
    }

    encoded, err := json.Marshal(invoice)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(encoded))
}
Enter fullscreen mode Exit fullscreen mode

In production, the idempotency key belongs on both sides of the boundary: send it with the inference request, and enforce a unique constraint on the Postgres job row. Persist the attempt before calling the model, commit the validated result with a compare-and-set state transition, and let a reconciliation worker inspect attempts left in a nonterminal state. Don't infer success from a client timeout. If a request is retried after an ambiguous disconnect, the audit record must make the duplicate attempt visible while the unique state transition prevents a duplicate payable invoice.

The sample logs failures for clarity; a service should classify them. A 429 is retryable with bounded backoff and Retry-After; malformed JSON and arithmetic disagreement are review outcomes; authentication and contract errors require operator action. None should be collapsed into an empty invoice.

The portability budget and its exit triggers

We rejected three permanent direct-provider adapters as the default because their maintenance and reconciliation cost is disproportionate for a small team doing common chat and JSON extraction. That rejection is conditional. Stick with a direct OpenAI, Anthropic, or Google integration when a vendor-native feature is the reason the product works, when its precise semantics must reach application code, or when procurement and data-governance rules require a direct agreement and isolated credentials.

A normalized runtime is also not suitable when the organization needs to operate its own policy enforcement point, implement custom failover semantics, or prove controls that a managed intermediary cannot satisfy. In those cases, build or adopt a self-owned gateway and budget for adapter drift, key rotation, per-provider audit normalization, and on-call responsibility. The gateway is infrastructure, not a weekend abstraction.

There are capability boundaries too. Infrai currently has no dedicated moderation endpoint, so moderation would require a chat model with a JSON-schema fallback; that is not equivalent to a specialized moderation product. ASR is marked unavailable in the model catalog, real-time voice session key status is pending and limited to the western region, and image upscaling is limited to Lanc. Keep those workloads outside this invoice decision unless their status and semantics meet the product requirement.

The ADR should therefore be reviewed when extraction starts depending on native tools, quality misses the approved corpus threshold, measured tail latency violates the ingest objective, or a compliance owner changes the permitted data flow. Until one of those conditions occurs, a narrow multi-model boundary plus application-owned idempotency and audit history is the practical selection.

Sources

Top comments (0)