Building a gin openai compatible endpoint means accepting the exact JSON contract that OpenAI clients emit and returning the same shape, while proxying to a real model backend. This tutorial implements a minimal Gin service in Go that proxies /v1/chat/completions to an upstream OpenAI-compatible API, handles both buffered and streaming responses, and stays faithful to the wire format.
Prerequisites
- Go 1.21 or newer
github.com/gin-gonic/gin(v1.9+)- An API key for an OpenAI-compatible backend (OpenAI, or a gateway such as n4n.ai which provides one OpenAI-compatible endpoint for 240+ models)
curlfor local verification
Scaffold the module
mkdir gin-llm-proxy && cd gin-llm-proxy
go mod init example.com/gin-llm-proxy
go get github.com/gin-gonic/gin
You now have a Go module ready for a single-file server.
Model the OpenAI contract
OpenAI’s chat completions API is strict about field names. If you deviate, SDKs like openai-python or LangChain break. Define the minimal request and response structs you need, but prefer forwarding raw JSON for unknown fields.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream,omitempty"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type Choice struct {
Index int `json:"index"`
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
type ChatResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage Usage `json:"usage"`
}
For a truly compatible surface, forward the raw request body instead of binding to a struct. SDKs send temperature, top_p, n, stop, and more. We’ll use c.GetRawData() later.
Implement the proxy handler
Create main.go. The handler reads the incoming body, issues an authenticated POST to the upstream, and copies the response back with the same status code and content type.
package main
import (
"io"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.POST("/v1/chat/completions", chatCompletions)
r.Run(":8080")
}
func chatCompletions(c *gin.Context) {
raw, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
upstream := os.Getenv("UPSTREAM_BASE") // e.g. https://api.openai.com/v1
key := os.Getenv("UPSTREAM_API_KEY")
req, err := http.NewRequest(http.MethodPost, upstream+"/chat/completions", bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request build failed"})
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream unreachable"})
return
}
defer resp.Body.Close()
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
io.Copy(c.Writer, resp.Body)
}
This is a complete non-streaming gin openai compatible endpoint. It forwards everything verbatim, so the response shape is guaranteed to match the upstream.
Streaming with Server-Sent Events
OpenAI streams incremental tokens as text/event-stream. Clients set "stream": true. Our raw forward already sends that flag upstream; we just need to flush chunks as they arrive instead of buffering.
Modify the response copy section:
if resp.Header.Get("Content-Type") == "text/event-stream" {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming unsupported"})
return
}
buf := make([]byte, 4096)
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
c.Writer.Write(buf[:n])
flusher.Flush()
}
if readErr != nil {
break
}
}
return
}
Place this before the io.Copy fallback. Gin’s c.Writer implements http.Flusher under the default server.
Run and test
Set environment variables and start the server.
export UPSTREAM_BASE="https://api.openai.com/v1"
export UPSTREAM_API_KEY="sk-your-key"
go run main.go
In another shell, send a buffered request:
curl -X POST localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Say hi"}],"stream":false}'
Expected output (truncated):
{
"id": "chatcmpl-abc123",
"object": "chat.completion",
"created": 1700000000,
"model": "gpt-3.5-turbo",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Hi there! How can I help?"},
"finish_reason": "stop"
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 6, "total_tokens": 16}
}
Now test streaming:
curl -X POST localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Count to 3"}],"stream":true}'
You should see SSE frames:
data: {"id":"chatcmpl-xyz","object":"chat.completion.chunk","choices":[{"delta":{"content":"1"}}]}
data: {"id":"chatcmpl-xyz","object":"chat.completion.chunk","choices":[{"delta":{"content":" 2"}}]}
data: [DONE]
Production considerations
The code above is a starting point, not a finished service. Harden it before real traffic:
- Cancel upstream on client disconnect. Use
c.Request.Context()as the request context so a dropped connection stops the upstream call. - Timeout tuning. LLM calls can run long. Set
client.Timeoutto zero and rely on context deadlines, or use a generous value like 120s. - Metering. If you operate the backend yourself, parse
usagefrom the response and emit per-token metrics. Gateways such as n4n.ai handle per-token usage metering and automatic fallback when a provider is rate-limited, which removes this burden. - Routing directives. OpenAI-compatible clients sometimes send
headersor model aliases. If you proxy to a multi-provider gateway, honor client routing hints and forward provider cache-control headers. - Raw forwarding caveat.
c.GetRawData()consumes the body once. If you need middleware logging, read it once and store it in the context.
A gin openai compatible endpoint is fundamentally a translation and proxy layer. Keep the contract intact, stream when asked, and push provider-specific resilience to the edge or to a gateway you trust. The 30 lines of Go above are enough to get a real SDK talking to your infrastructure today.