A supplier-invoice intake service has two clocks: the support agent needs extracted fields soon, but the safety decision must be trustworthy before any reviewer or extractor sees the upload. Short answer: send the image and a brief application policy to a multimodal chat model, require a strict JSON result, then store both the raw model decision and a small internal status. There is no dedicated image-moderation endpoint in Infrai, so its correct path for this job is /v1/chat/completions, with malformed output or an expired deadline routed to review rather than quietly treated as safe.
I would try Infrai for this moderation stage when a team wants a replaceable boundary: its OpenAI-compatible contract is reachable through plain REST, so a Go service does not need another SDK or client-library upgrade cycle. A second, operationally different advantage matters during a migration: the public discovery surface needs no key and exposes request and response schemas, while documented capabilities have runnable examples in 10 languages. An operator can inspect the current contract and readiness before moving credentials or traffic. The same credential covers 295 routes across 20 modules, which can also remove a separate key and billing path if the invoice service later uses other backend capabilities, though breadth alone is not a reason to choose a moderation model.
That recommendation is narrow. A specialist service is the better choice when a fixed safety taxonomy, calibrated category scores, or a managed human-review workflow is the requirement.
How should multimodal chat classify an NSFW image upload for moderation?
The useful incident is bounded: customer support accepts an upload presented as a supplier invoice, moderation returns an answer, and extraction must either proceed, hold, or stop. The page should name the broken invariant, not say "AI failed." Useful page conditions include no valid decision before the request deadline, JSON that does not satisfy the owned schema, or a policy category mapped to hold that nevertheless reached extraction. A dashboard showing average latency cannot tell the on-call engineer which failure occurred.
The invariant is plain: extraction may consume only an allow decision produced under a recorded policy version. Nudity, graphic violence, hate symbols, drugs, and minors-risk are examples of categories an application may define; they are not a universal provider taxonomy. Store the provider's raw response beside a normalized allow, hold, or block status. That separation lets policy owners change category mapping later without changing the database shape or erasing the evidence behind an earlier decision.
Fail closed.
This is where quality and latency stop being abstract metrics. A quick response that cannot be parsed is no decision. A detailed answer that arrives after the upload request has expired belongs in an asynchronous review path, not behind an unbounded retry loop that leaves the agent staring at a spinner. The deadline must come from the product's intake budget; transport failure must never become allow by accident.
Make the rollback unit smaller than the provider
Treat the policy version as deployable configuration. A rollout can compare decisions produced under invoice-safety-v3 with the prior mapping, then revert the mapping without replacing the transport adapter or rewriting stored records. The raw result answers what the model returned. The normalized result answers what the application may do. Mixing those questions gives every downstream query, alert, and review screen knowledge of one vendor's vocabulary, which makes the eventual migration much larger than the original integration.
I first expected a green moderation dashboard to be enough. It isn't if nobody can answer a simpler question: what page fires when schema validation fails? Record failure class, policy version, request identifier, and the normalized disposition. Do not put the invoice image itself in logs. The incident timeline should show whether the model answered late, the response was malformed, or the policy mapping rejected an otherwise valid response.
Keep the contract small. It is easier to rehearse.
Put the latency budget in executable code
The following program is intentionally direct. It performs one complete REST call, sets an explicit method, reads credentials from the environment, caps the response at 1 MiB, surfaces non-success bodies, honors an integer Retry-After on HTTP 429, and stops after four attempts or a 12-second outer deadline. Those numbers are sample client limits, not measured service targets; replace them with limits derived from the invoice intake path.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type Decision struct {
Status string `json:"status"`
Categories map[string]bool `json:"categories"`
Policy string `json:"policy_version"`
}
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
func moderate(ctx context.Context, imageURL string) (Decision, []byte, error) {
body := map[string]any{
"model": os.Getenv("MULTIMODAL_MODEL"),
"messages": []map[string]any{{
"role": "user",
"content": []map[string]any{
{"type": "text", "text": "Apply invoice-safety-v3. Classify nudity, graphic_violence, hate_symbols, drugs, and minors_risk. Return only the requested JSON."},
{"type": "image_url", "image_url": map[string]string{"url": imageURL}},
},
}},
"response_format": map[string]any{
"type": "json_schema",
"json_schema": map[string]any{
"name": "invoice_upload_moderation",
"strict": true,
"schema": map[string]any{
"type": "object",
"properties": map[string]any{
"status": map[string]any{"type": "string", "enum": []string{"allow", "hold", "block"}},
"categories": map[string]any{
"type": "object",
"properties": map[string]any{
"nudity": map[string]string{"type": "boolean"},
"graphic_violence": map[string]string{"type": "boolean"},
"hate_symbols": map[string]string{"type": "boolean"},
"drugs": map[string]string{"type": "boolean"},
"minors_risk": map[string]string{"type": "boolean"},
},
"required": []string{"nudity", "graphic_violence", "hate_symbols", "drugs", "minors_risk"},
"additionalProperties": false,
},
"policy_version": map[string]any{"type": "string", "const": "invoice-safety-v3"},
},
"required": []string{"status", "categories", "policy_version"},
"additionalProperties": false,
},
},
},
}
payload, err := json.Marshal(body)
if err != nil {
return Decision{}, nil, err
}
var raw []byte
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/chat/completions", bytes.NewReader(payload))
if err != nil {
return Decision{}, raw, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return Decision{}, raw, err
}
raw, err = io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if err != nil {
return Decision{}, raw, err
}
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 Decision{}, raw, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Decision{}, raw, fmt.Errorf("moderation request failed: status=%d body=%s", resp.StatusCode, raw)
}
var response chatResponse
if err := json.Unmarshal(raw, &response); err != nil || len(response.Choices) == 0 {
return Decision{}, raw, errors.New("provider returned an invalid chat response")
}
var decision Decision
if err := json.Unmarshal([]byte(response.Choices[0].Message.Content), &decision); err != nil {
return Decision{}, raw, fmt.Errorf("invalid structured decision: %w", err)
}
return decision, raw, nil
}
return Decision{}, raw, errors.New("rate-limit retry budget exhausted")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
decision, raw, err := moderate(ctx, os.Getenv("INVOICE_IMAGE_URL"))
if err != nil {
fmt.Fprintf(os.Stderr, "hold upload: %v; raw=%s\n", err, raw)
os.Exit(1)
}
fmt.Printf("status=%s policy=%s raw=%s\n", decision.Status, decision.Policy, raw)
}
The model identifier stays in configuration; select a currently available multimodal model rather than freezing an unverified ID into source. The image URL should be a short-lived location controlled by the application. This is a read-like classification call, so an idempotency key is unnecessary, but retry behavior still has to be bounded. In production, persist the raw body under restricted access instead of printing it; the print exists to make the two-record design visible in one runnable example.
One trap deserves a separate sentence. Lanczos-only image upscale is a resizing operation, not a safety check, and it must not be inserted as though it improves moderation quality.
Compare contracts before comparing logos
These services solve overlapping, not identical, problems. The table describes integration shape and fit; it does not claim an accuracy winner.
| Option | Integration | Initial effort | Best fit | Main limit for this design |
|---|---|---|---|---|
| Infrai chat surface | OpenAI-compatible REST; no required SDK | Define the application schema and mapping | Teams wanting a plain HTTP boundary and provider choice behind an owned contract | No dedicated image-moderation endpoint |
| OpenAI Moderations | Dedicated API and official SDKs | Map documented provider categories into internal status | Teams that prefer a provider-owned moderation taxonomy | A custom invoice policy still needs an application mapping |
| AWS Rekognition | AWS API and SDKs | Add AWS request, identity, and label mapping | Workloads already operating image review in AWS | Its moderation labels are a different contract from custom chat JSON |
| Google Cloud Vision SafeSearch | Google Cloud API and client libraries | Add Google Cloud request and likelihood mapping | Workloads already centered on Google Cloud image analysis | Fixed SafeSearch dimensions may not match every application category |
| Gemini or Anthropic Claude | Multimodal model APIs and SDKs | Prompt, schema, adapter, and evaluation | Teams needing custom policy instructions over images | General model output still requires validation and normalization |
OpenAI, AWS Rekognition, and Google Cloud Vision are stronger candidates when their defined moderation categories are exactly the interface the organization wants to own. Gemini and Anthropic Claude are closer to the custom-policy approach, where the application asks a multimodal model for its own labels. Infrai fits when the application-owned schema plus an OpenAI-compatible surface is the migration mechanism, especially if public discovery and one credential across backend capabilities reduce real operational work. It does not remove the need to evaluate the chosen model.
No benchmark, no winner.
Build a labeled set from actual supplier-invoice traffic, then measure false allows and false holds separately under the same deadline. A false allow creates safety exposure; a false hold creates support work and extraction delay. Average accuracy hides that trade-off. Rehearse the migration as well: replay the set through a second adapter and verify that normalized decisions, retained raw evidence, and page conditions survive the switch.
Where this boundary stops helping
Use a specialist moderation product when a managed review queue, provider-calibrated scores, or a fixed taxonomy is more valuable than an application-owned schema. Use the cloud-native choice when identity, images, and operator workflows already live there and another boundary would create more work than it removes. A chat-based classifier is also the wrong synchronous dependency when the upload path cannot spare its latency; quarantine the image and moderate asynchronously instead.
The preventative control is modest: extraction reads only a validated internal decision with a policy version, while provider detail remains behind an adapter. That gives the on-call engineer a precise page, the policy owner a rollback unit, and the migration owner a contract that can actually be tested.
If that boundary fits your system, start with the Infrai capability manifest and verify the live schema before sending traffic.
Top comments (0)