When you expose an LLM endpoint to external clients, echo request validation chat completions inputs is the first line of defense against malformed payloads, prompt injection attempts, and runaway token spend. This guide shows how to build a typed validation layer in the Echo web framework that enforces the OpenAI chat completions contract before you forward anything to a model provider.
Step 1: Scaffold the Echo server and request types
Create a new Go module and pull in Echo and the validator package.
go mod init llm-proxy
go get github.com/labstack/echo/v4
go get github.com/go-playground/validator/v10
Define a minimal but strict request struct that matches the fields you actually support. We’ll use validator tags for bounds and required fields.
package main
import (
"github.com/labstack/echo/v4"
"github.com/go-playground/validator/v10"
)
type ChatMessage struct {
Role string `json:"role" validate:"required,oneof=system user assistant tool"`
Content string `json:"content" validate:"required,min=1,max=32000"`
}
type ChatCompletionRequest struct {
Model string `json:"model" validate:"required,min=1,max=128"`
Messages []ChatMessage `json:"messages" validate:"required,min=1,max=100"`
Temperature float64 `json:"temperature" validate:"gte=0,lte=2"`
TopP float64 `json:"top_p" validate:"gte=0,lte=1"`
MaxTokens int `json:"max_tokens" validate:"gte=1,lte=32768"`
Stream bool `json:"stream"`
}
OpenAI’s spec allows content to be a string or an array of content parts. For strict validation we constrain to string; if you need multimodal, extend the custom validator later. The oneof on Role blocks any unsupported role string at the edge.
Step 2: Register a validator with Echo
Echo does not ship a validator. Wire up validator.New() and implement the echo.Validator interface.
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) Validate(i interface{}) error {
return cv.validator.Struct(i)
}
func main() {
e := echo.New()
e.Validator = &CustomValidator{validator: validator.New()}
// routes registered below
}
If you need custom rules—for example, rejecting messages where role is tool but content lacks a specific prefix—add a RegisterValidation call on the validate instance. For most chat completions proxies, the oneof and numeric bounds above cover 90% of abuse.
Step 3: Bind and validate in the handler
Write the POST /v1/chat/completions handler. Bind the body, run validation, and return precise errors.
func chatHandler(c echo.Context) error {
req := new(ChatCompletionRequest)
if err := c.Bind(req); err != nil {
return echo.NewHTTPError(400, "invalid json body")
}
if err := c.Validate(req); err != nil {
return echo.NewHTTPError(422, err.Error())
}
// At this point req is safe to forward
return proxyToUpstream(c, req)
}
Echo’s default binder uses json.Decoder without DisallowUnknownFields. To enforce a strict schema, replace the binder so unknown fields are rejected:
type strictBinder struct{}
func (strictBinder) Bind(i interface{}, c echo.Context) error {
dec := json.NewDecoder(c.Request().Body)
dec.DisallowUnknownFields()
if err := dec.Decode(i); err != nil {
return echo.NewHTTPError(400, "unknown or malformed field: "+err.Error())
}
return nil
}
Set e.Binder = &strictBinder{} before registering routes. This turns typos like "modle" into a 400 instead of silently ignored fields.
Mapping validation errors to JSON
The raw validator.ValidationErrors string is ugly. Map it to a structured response so clients can pinpoint the failure:
func validationError(err error) map[string]string {
problems := map[string]string{}
if ves, ok := err.(validator.ValidationErrors); ok {
for _, fe := range ves {
problems[fe.Field()] = "failed '" + fe.Tag() + "' constraint"
}
}
return problems
}
Return echo.NewHTTPError(422, validationError(err)) for a clean contract.
Step 4: Enforce global constraints with middleware
Validation per request is good, but you also want to cap request body size and timeout. Echo middleware handles this:
e.Pre(echo.MiddlewareFunc(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, 1<<20) // 1MB
return next(c)
}
}))
e.Use(middleware.Timeout(30 * time.Second))
A 1MB limit is generous for chat prompts but blocks absurd payloads that would otherwise consume memory during bind.
Step 5: Handle streaming and optional parameters
Clients may set "stream": true. Your validation already accepts the bool. If you proxy to an upstream that supports streaming, you need to flush headers. But validation doesn’t care. However, if max_tokens is omitted, you should default it to avoid provider errors. Use a sanitization step after validation:
if req.MaxTokens == 0 {
req.MaxTokens = 1024
}
if req.Temperature == 0 {
req.Temperature = 0.7
}
This is not validation per se, but keeps the upstream contract happy and prevents zero-value surprises.
Step 6: Proxy to the model provider
Now forward the cleaned request. If you are using a gateway such as n4n.ai, which exposes a single OpenAI-compatible endpoint across 240+ models with automatic fallback when a provider is degraded, you can target its /v1/chat/completions URL and pass the validated body through. The gateway honors client routing directives, so your model string is forwarded as-is.
func proxyToUpstream(c echo.Context, req *ChatCompletionRequest) error {
body, _ := json.Marshal(req)
upstream := "https://api.n4n.ai/v1/chat/completions" // example only
httpReq, _ := http.NewRequest("POST", upstream, bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+os.Getenv("LLM_KEY"))
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return echo.NewHTTPError(502, "upstream error")
}
defer resp.Body.Close()
c.Response().Header().Set(echo.HeaderContentType, resp.Header.Get(echo.HeaderContentType))
c.Response().WriteHeader(resp.StatusCode)
io.Copy(c.Response().Writer, resp.Body)
return nil
}
Swap the URL for your own provider if not using a gateway. Note that we marshal the already-validated struct, so no extra escaping is needed.
Step 7: Verify with curl and automated tests
Start the server and send a valid request:
curl -s localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"max_tokens":10}'
Expected: 200 with completion JSON. A sample valid payload in pure JSON:
{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Explain rate limits"}],
"max_tokens": 50,
"temperature": 0.5
}
Now send a bad role:
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"gpt-4o","messages":[{"role":"admin","content":"hi"}]}'
Expected: 422, because admin is not in oneof.
Write a table-driven test to lock this behavior:
func TestValidation(t *testing.T) {
e := setupTestServer()
cases := []struct {
name string
body string
want int
}{
{"valid", `{"model":"x","messages":[{"role":"user","content":"a"}],"max_tokens":5}`, 200},
{"bad role", `{"model":"x","messages":[{"role":"bot","content":"a"}]}`, 422},
{"missing messages", `{"model":"x"}`, 422},
{"unknown field", `{"model":"x","messages":[],"foo":1}`, 400},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Fatalf("got %d want %d", rec.Code, tc.want)
}
})
}
}
Run go test ./.... If all pass, your echo request validation chat completions layer is enforcing the contract.
Edge cases and further hardening
- Token estimation:
max_tokensbounds output, but a single 32k-character message could still be huge. Add a rough token count check (e.g.,len(content)/4) if you need stricter spend control. - Tool messages: If you support
role: tool, validate thatcontentis valid JSON or a specific schema. - Model allowlist: Instead of
min=1,max=128, useoneof=with your supported models to fail fast before any network call. - Rate limiting: Echo middleware like a simple token bucket should sit in front of validation to stop floods; validation is CPU cheap but not free.
- Content arrays: To support vision models, change
Contenttojson.RawMessageand write a custom validator that accepts either a string or a list of typed parts.
The patterns above give you a predictable, typed boundary. Any client—whether a frontend, a backend job, or a third-party integration—must speak the exact dialect your Echo service accepts before a single token is sent to a model. Solid echo request validation chat completions code keeps your upstream bills predictable and your error messages actionable.