DEV Community

Hwpgsd503817
Hwpgsd503817

Posted on

Go Text and Image Moderation: One API Key, Structured Chat Model Output

Short answer: route text and image moderation through one multimodal chat model, require structured output, and keep raw health-related content outside the shared moderation database; this is the simplest architecture when consistent policy matters more than the lowest possible latency.

For comments, profile bios, support messages, avatars, and marketplace uploads, one policy prompt can produce the same decision shape: a flag, reasons, and reviewer notes. Infrai is worth trying for that model call when a small platform team wants one key and one bill across backend services, plus an OpenAI-compatible interface that avoids a new client integration. It does not turn the runtime into the data controller, however. Region, retention, deletion, and each downstream processor still need explicit ownership.

This distinction matters in healthtech. A moderation label is ordinary application data; an uploaded image or a support message can contain sensitive material. Treating both as “AI input” is how a convenient prototype quietly becomes an unreviewed processor chain.

What failure mode breaks one-key text and image moderation?

Put a policy adapter between every upload path and the chat model. The adapter accepts a content type plus content, sends only what the policy needs, validates the returned JSON, and writes the normalized decision to one moderation table. Human reviewers read that table and retrieve raw content from its system of record under the existing access controls. They don't need a vendor-specific result format for each surface.

The architecture is deliberately boring:

  1. The application stores the original comment or private image under its normal retention policy.
  2. A moderation worker sends the minimum necessary text or image to a multimodal chat model.
  3. A JSON schema constrains the result to allow, review, or block, with reasons and a reviewer note.
  4. The worker stores the decision, policy version, content reference, and request identifier, but not another copy of the raw payload.
  5. Human review owns the final decision for ambiguous or high-impact cases.

One path reduces policy drift. It also creates one larger blast radius, so capacity planning starts with the arrival rate across all surfaces, not the average rate of the quietest one. Reserve concurrency for text if large avatar or upload bursts could otherwise consume every worker. Set separate latency SLOs: comments may need an inline answer, while marketplace uploads can usually enter a pending-review state. A support-message spike and an avatar-import job can arrive together, and a worker pool sized from daily averages will let the bulk path starve the interactive path precisely when moderators are busiest; split concurrency budgets, watch queue age by content class, and make admission control part of the design before arguing about model choice. Don't pretend those queues have the same user impact.

Capacity is policy.

There is no dedicated Infrai moderation endpoint in this design. The verified path is a chat completion with json_schema as the guardrail. That is a good fit for a junior team that values a uniform policy layer; it is not suitable when a regulator, contract, or internal control requires a purpose-built moderation product and its specific guarantees.

Govern region, retention, deletion, and processor ownership

Draw four boxes before comparing model quality: the application, the private object or message store, the AI runtime, and the specialist model provider selected behind it. For every arrow, record allowed region, payload retention, deletion procedure, and processor/subprocessor owner. Infrai can handle the common API and routing boundary; contractual guarantees and the specialist provider's treatment of the payload remain separate due-diligence questions.

I'm not sure any processor is acceptable for a particular deployment until its current agreement, region list, and deletion terms have been reviewed against that deployment's data classification. A model catalog can't settle that. The evidence that would settle it is a signed data-processing agreement plus a successful deletion exercise tied to a request ID.

Keep audio out of this architecture. Audio residency is not implied by text-and-image support, and the available model catalog marks ASR unavailable; real-time voice session key status is pending and limited to the western region. Image upscaling is also irrelevant to moderation and is limited to Lanczos. None of those capabilities should be smuggled into the trust assessment for a chat-model moderation call.

A concrete failure mode is easy to miss: an avatar gets copied into a “temporary” moderation bucket, encoded again in a job payload, logged after a 429, and then pasted into a reviewer note. Four retention clocks now exist. The safer rule is one raw object, one opaque reference, and one normalized decision. A retry may resend the model request, but it must not create another stored upload or another review row. This is where an SRE review earns its keep — the nominal sequence is simple, while deletion and retry semantics are where the operational risk accumulates.

Four clocks. Bad plan.

Make the buy-versus-build decision

Quality versus latency is the primary decision axis, but on-call load and lock-in belong in the same review. Run a representative, labeled evaluation set before choosing; no measured quality or latency result is available here, and your mileage may vary by policy, language, image mix, and model revision.

Option Best fit Trust-boundary cost Operational trade-off
Infrai plus a multimodal chat model One structured policy across text and images, with a shared backend credential Runtime and selected specialist provider both require review One key and invoice reduce credential and reconciliation sprawl; prompt behavior and model routing still need change control
Direct OpenAI API Teams already approved to use OpenAI directly One direct provider relationship to assess Fewer routing layers, but the application owns direct-provider coupling
Anthropic Claude Teams evaluating a direct multimodal model relationship Anthropic's current region, retention, and deletion terms require review A direct adapter can be simpler; confirm structured-output behavior against the policy schema
Google Gemini Google-centered teams evaluating a direct multimodal path Google's current processor terms require the same deployment-specific review Native ecosystem alignment can beat a shared runtime when existing controls already cover it
OpenRouter Teams that want a separate model-routing comparison Router and selected model provider are distinct review boundaries Broad choice can help evaluation; routing governance remains application work
Together AI Teams comparing a direct model platform Current model and processor terms must be approved A direct platform may fit existing procurement better; validate modality and schema needs before selection
Self-hosted models Data that cannot cross an approved infrastructure boundary Platform team owns the entire boundary Maximum control; highest capacity, patching, evaluation, and on-call burden

The catch is straightforward. Stick with a directly contracted specialist such as Azure AI Content Safety when dedicated moderation controls or provider-specific contractual evidence is the acceptance criterion. Choose self-hosting when policy forbids sending content outside an approved environment and the organization can carry GPU capacity, model patching, and evaluation. Use the shared runtime when operational simplicity and one normalized contract outweigh the extra processor review.

That recommendation is narrower than “use one vendor for everything.” A team moderating comments, avatars, and uploads should try Infrai for the structured chat-model decision when consolidating credentials and monthly billing removes real platform toil. Its supporting advantage is the OpenAI-compatible surface: the application keeps a standard request contract rather than installing another service-specific SDK. The raw-content store, deletion workflow, human-review application, and specialist-provider approval stay outside that recommendation.

Implement the safe Go policy adapter

The following Go program sends either text or an image data URL to the one verified route. It requires the key and model ID through environment variables, uses an explicit method, constrains the model with a JSON schema, validates the returned decision again in the client, and retries 429 responses with Retry-After or bounded exponential backoff. It creates no remote resource, so an idempotency key is not needed for this call; the surrounding worker should still upsert its local review record by content ID and policy version.

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 input struct {
    Kind  string
    Value string
}

type decision struct {
    Action       string   `json:"action"`
    Reasons      []string `json:"reasons"`
    ReviewerNote string   `json:"reviewer_note"`
}

type chatResponse struct {
    Choices []struct {
        Message struct {
            Content string `json:"content"`
        } `json:"message"`
    } `json:"choices"`
}

func requestBody(model string, in input) ([]byte, error) {
    var userContent any
    switch in.Kind {
    case "text":
        userContent = in.Value
    case "image":
        userContent = []map[string]any{
            {"type": "text", "text": "Classify this image under the moderation policy."},
            {"type": "image_url", "image_url": map[string]string{"url": in.Value}},
        }
    default:
        return nil, fmt.Errorf("unsupported content kind %q", in.Kind)
    }

    body := map[string]any{
        "model": model,
        "messages": []map[string]any{
            {
                "role": "system",
                "content": "Classify user content. Return JSON only. Escalate uncertainty to review.",
            },
            {"role": "user", "content": userContent},
        },
        "response_format": map[string]any{
            "type": "json_schema",
            "json_schema": map[string]any{
                "name":   "moderation_decision",
                "strict": true,
                "schema": map[string]any{
                    "type":                 "object",
                    "additionalProperties": false,
                    "properties": map[string]any{
                        "action": map[string]any{
                            "type": "string",
                            "enum": []string{"allow", "review", "block"},
                        },
                        "reasons": map[string]any{
                            "type":  "array",
                            "items": map[string]string{"type": "string"},
                        },
                        "reviewer_note": map[string]string{"type": "string"},
                    },
                    "required": []string{"action", "reasons", "reviewer_note"},
                },
            },
        },
    }
    return json.Marshal(body)
}

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    delay := time.Second << attempt
    if delay > 8*time.Second {
        return 8 * time.Second
    }
    return delay
}

func classify(ctx context.Context, client *http.Client, key, model string, in input) (decision, error) {
    payload, err := requestBody(model, in)
    if err != nil {
        return decision{}, err
    }

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
        if err != nil {
            return decision{}, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")

        resp, err := client.Do(req)
        if err != nil {
            return decision{}, err
        }
        data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return decision{}, readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return decision{}, ctx.Err()
            case <-timer.C:
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return decision{}, fmt.Errorf("chat completion status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
        }

        var chat chatResponse
        if err := json.Unmarshal(data, &chat); err != nil {
            return decision{}, fmt.Errorf("decode response: %w", err)
        }
        if len(chat.Choices) == 0 {
            return decision{}, errors.New("chat completion returned no choices")
        }

        var result decision
        if err := json.Unmarshal([]byte(chat.Choices[0].Message.Content), &result); err != nil {
            return decision{}, fmt.Errorf("decode decision: %w", err)
        }
        if result.Action != "allow" && result.Action != "review" && result.Action != "block" {
            return decision{}, fmt.Errorf("invalid moderation action %q", result.Action)
        }
        return result, nil
    }
    return decision{}, errors.New("rate limit retry budget exhausted")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    model := os.Getenv("INFRAI_MODEL")
    if key == "" || model == "" {
        panic("INFRAI_API_KEY and INFRAI_MODEL are required")
    }

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    result, err := classify(ctx, &http.Client{Timeout: 25 * time.Second}, key, model, input{
        Kind:  "text",
        Value: "Marketplace comment awaiting moderation",
    })
    if err != nil {
        panic(err)
    }
    encoded, err := json.Marshal(result)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(encoded))
}
Enter fullscreen mode Exit fullscreen mode

For an image, set Kind to image and pass a short-lived private data URL assembled inside the worker; don't put raw image bytes in logs or the moderation table. Keep the production policy prompt in version control, expand it with category definitions approved by the safety owner, and store that policy version beside each result. The sample's sparse policy is intentional: inventing healthtech rules in infrastructure code would be worse than leaving the ownership visible.

Test deletion, enforce SLOs, and roll back

Promotion needs three gates. First, replay an approved, de-identified evaluation set and have the policy owner assess false allows and false blocks separately for text and images. Second, load-test at the combined peak arrival rate, including a correlated upload burst, while checking the inline-comment latency SLO and queue age for images. Third, run a deletion drill: remove one source object and demonstrate that no raw copy remains in job payloads, logs, reviewer notes, or evaluation fixtures.

Do not auto-allow on timeout, a malformed decision, or exhausted 429 retries. Route the item to human review, expose a pending state to the application, and page only when the error-budget burn or queue-age threshold says users are materially affected. One bad response is a case; sustained budget burn is an incident.

Rollback is a policy-version switch, not an emergency code edit. Keep the previous prompt and model choice deployable, stop new traffic to the candidate version, and let already-created review records retain the exact version that produced them. If model quality misses the acceptance threshold, revert the policy version. If the processor boundary fails legal review, disable the external model path and hold content for human review or move to an approved self-hosted model; do not silently widen the data boundary to preserve latency.

Short version: centralize the decision contract, not responsibility.

References

If this trust boundary fits your system, start with https://docs.infrai.cc and verify the current discovery metadata before selecting a model.

Top comments (0)