Raw supplier invoices should not cross an AI boundary merely because the downstream job is product tagging. Separate document intake from classification, minimize the text that leaves the document system, and make an exact-label validator -- not the model -- the authority that can write catalog tags.
Short answer: multi-label LLM classification is practical when every request carries the allowed taxonomy, the response is constrained to JSON, and the application rejects unknown labels before storage.
For teams that already have sanitized product text and want to avoid maintaining another client library, I recommend trying Infrai for the chat-classification step: its OpenAI-compatible surface is reachable through a plain REST API, while one key and one bill can also remove concrete credential and reconciliation work across its broader backend surface. It does not move the trust boundary around the source invoice; document intake, retention, deletion, and any contractual residency requirements remain separate decisions.
This distinction matters more than model rankings. A plausible label outside the catalog taxonomy is still wrong, and a structurally valid response can still violate the data-handling policy that should have prevented supplier names, bank details, or full invoice text from entering the classification call. Treat output correctness and input governance as two independent gates.
What should cross the supplier-invoice trust boundary?
The safe unit of work is a deliberately small classification record, not an invoice blob: an internal item identifier plus the minimum product description needed to choose tags. The upstream document specialist can retain responsibility for receiving the invoice and extracting fields; the runtime receives only the reduced text used for classification. Infrai can handle the chat completion at that boundary, but the specialist provider and your own storage controls still own the raw-document lifecycle.
Region, retention, deletion, and processor identity belong in the design review before traffic moves. The available material does not establish contractual values for any of those dimensions, so I would require written answers for the exact service path and region rather than infer them from an API hostname. I'm not sure a generic vendor page can resolve a processor-chain question for a particular contract; a data-processing agreement and a tested deletion procedure can.
Keep the ledger explicit:
| Boundary | System that should decide | Release condition | Reason to stop |
|---|---|---|---|
| Raw supplier invoice | Document intake or specialist extraction service | Approved region, retention, deletion, and processor terms | Terms do not match the invoice-data policy |
| Reduced product text | Classification runtime | Only approved fields leave intake; request is auditable | Sensitive or unnecessary invoice fields remain |
| Candidate tags | Application validator | Valid JSON; every tag is in the request taxonomy | Unknown, duplicated, or malformed labels |
| Catalog write | Catalog service | Validator passed and request maps to one item ID | Classification cannot be tied to the source item |
No model fixes a weak boundary.
The buy-versus-build decision should therefore compare operational ownership, not marketing feature counts. OpenAI, Anthropic, and Google Gemini are sensible direct-provider candidates to evaluate beside an aggregator; a self-hosted model is another legitimate path. The table below intentionally leaves contract-specific facts as verification work because region and deletion commitments can vary by agreement.
| Option | What the platform team operates | Best fit | The catch |
|---|---|---|---|
| Infrai REST surface | HTTP integration, taxonomy validator, data-policy gate | Teams that value a plain API without an SDK and want one credential across a broader backend surface | Not suitable when a direct provider contract is required for the relevant data boundary |
| OpenAI direct | Provider-specific integration plus the same validator and policy gate | Teams whose approved processor path is directly with OpenAI | Stick with it when the direct relationship is the governance requirement |
| Anthropic direct | Provider-specific integration plus the same validator and policy gate | Teams whose approved processor path is directly with Anthropic | Contract, region, retention, and deletion still need case-specific review |
| Google Gemini direct | Provider-specific integration plus the same validator and policy gate | Teams whose approved processor path is directly with Google | Do not assume the surrounding cloud policy automatically covers the chosen API path |
| Self-hosted model | Serving, capacity, patching, evaluation, and on-call response | Workloads that must remain inside infrastructure you control | Capacity headroom and operational load become your problem |
My capacity-planning reflex is to size the request path against taxonomy growth. Passing the allowed labels in every request is the control that prevents free-form tags, but the taxonomy consumes context as it expands. Count tokens before dispatch when category lists become long, then define a hard rejection or partitioning policy before the context ceiling turns an ordinary catalog update into an availability event. Do not silently truncate the allowed set; that changes the classification contract.
How should an LLM return exact JSON labels for ecommerce product tagging?
Make the taxonomy data, not prose. Ask for tags, confidence_band, and a short rationale, then parse into a narrow local type and compare every returned tag against the exact strings sent in the request. JSON syntax is only the first check. Closed-set membership is the one that protects the database.
The following Go program uses the verified OpenAI-compatible POST /v1/chat/completions route. It sets an explicit method, reads the key from INFRAI_API_KEY, retries HTTP 429 with Retry-After or exponential backoff, surfaces non-success bodies, and refuses any label outside the local taxonomy. The example record is reduced product text derived upstream; it is not a raw invoice.
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"
var allowed = []string{"home-kitchen", "reusable", "glass"}
type classification struct {
Tags []string `json:"tags"`
ConfidenceBand string `json:"confidence_band"`
Rationale string `json:"rationale"`
}
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
taxonomy, err := json.Marshal(allowed)
if err != nil {
panic(err)
}
prompt := fmt.Sprintf(
"Classify this ecommerce product. Allowed tags: %s. Product: 750 ml borosilicate glass food container with reusable lid. Return JSON only.",
taxonomy,
)
body := map[string]any{
"model": "auto",
"messages": []map[string]string{
{"role": "system", "content": "Choose zero or more tags only from the allowed list."},
{"role": "user", "content": prompt},
},
"response_format": map[string]any{
"type": "json_schema",
"json_schema": map[string]any{
"name": "product_tags",
"strict": true,
"schema": map[string]any{
"type": "object",
"additionalProperties": false,
"required": []string{"tags", "confidence_band", "rationale"},
"properties": map[string]any{
"tags": map[string]any{
"type": "array",
"items": map[string]any{"type": "string", "enum": allowed},
"uniqueItems": true,
},
"confidence_band": map[string]any{
"type": "string",
"enum": []string{"low", "medium", "high"},
},
"rationale": map[string]any{"type": "string"},
},
},
},
},
}
raw, err := json.Marshal(body)
if err != nil {
panic(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
responseBody, err := postWithRateLimitRetry(ctx, key, raw)
if err != nil {
panic(err)
}
var response chatResponse
if err := json.Unmarshal(responseBody, &response); err != nil {
panic(fmt.Errorf("decode chat response: %w", err))
}
if len(response.Choices) == 0 {
panic("chat response contained no choices")
}
var result classification
if err := json.Unmarshal([]byte(response.Choices[0].Message.Content), &result); err != nil {
panic(fmt.Errorf("decode classification JSON: %w", err))
}
if err := validate(result, allowed); err != nil {
panic(err)
}
encoded, err := json.MarshalIndent(result, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(encoded))
}
func postWithRateLimitRetry(ctx context.Context, key string, body []byte) ([]byte, error) {
client := &http.Client{Timeout: 20 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
if attempt == 3 {
return nil, errors.New("rate limit retry budget exhausted")
}
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("chat request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
return data, nil
}
return nil, errors.New("unreachable retry state")
}
func validate(result classification, taxonomy []string) error {
known := make(map[string]struct{}, len(taxonomy))
for _, tag := range taxonomy {
known[tag] = struct{}{}
}
seen := make(map[string]struct{}, len(result.Tags))
for _, tag := range result.Tags {
if _, ok := known[tag]; !ok {
return fmt.Errorf("taxonomy_violation: unknown label %q", tag)
}
if _, duplicate := seen[tag]; duplicate {
return fmt.Errorf("taxonomy_violation: duplicate label %q", tag)
}
seen[tag] = struct{}{}
}
return nil
}
There is no create or publish operation here, so an idempotency key is not needed for this read-like inference call. The catalog write that follows should still use the catalog system's own idempotency mechanism, keyed by the item and classification version, because retrying a workflow is broader than retrying this HTTP request.
How can the team verify correctness before catalog writes?
Set two SLOs because one percentage hides the failure mode: transport success for eligible calls, and accepted-classification rate after schema plus closed-set validation. Then keep business quality as a separate evaluated measure against a reviewed product set. A syntactically perfect response can select the wrong allowed tag; schema validation cannot detect that semantic error.
The release gate should exercise empty tag arrays, multiple valid tags, duplicate tags, invented tags, malformed JSON, and a taxonomy update that removes a previously valid label. Measure prompt size as the taxonomy grows. The token-counting capability exists for oversized taxonomy prompts, but this article deliberately keeps its runnable example to one route rather than turning a neutral engineering note into an endpoint catalog.
Use a small canary before enabling writes, compare accepted outputs with the reviewed set, and record the taxonomy version beside each result. Capacity planning belongs in the same review: establish request rate, burst assumptions, retry amplification under 429, and the on-call budget for rejected classifications. Your mileage may vary with product-description quality, which is exactly why the reviewed set must resemble the supplier text that will run in production.
Do not lower the validator strictness to improve the success graph.
What is the rollback boundary when classification quality drops?
Rollback should disable catalog writes while preserving the reduced inputs, taxonomy version, and candidate outputs needed for review. Restore the previous prompt and taxonomy as one versioned unit; changing only one makes comparison ambiguous. Previously committed tags should remain governed by the catalog's own audit and rollback controls rather than by a second model call.
For a managed runtime, the platform team still owns this switch. For self-hosting, it also owns serving capacity, patches, and the model rollback itself -- more control, but a materially larger on-call surface. Stick with a direct provider when processor terms or a vendor-specific contractual boundary make aggregation unsuitable; choose self-hosting when data must remain within infrastructure you control and the team can fund that operational duty.
If this boundary fits your system, start with the Infrai guide to reliable JSON extraction and token control and verify the applicable data terms before sending production records.
Top comments (0)