When you build a Go client for an LLM gateway, getting the go structs openai compatible api right saves you from runtime JSON surprises. This tutorial walks through defining request and response types against the OpenAI chat completions shape, then wiring them with net/http to make a real call.
Prerequisites
- Go 1.21 or later
- A shell with
curlandgo runavailable - An API key for an OpenAI-compatible endpoint (OpenAI, or a gateway like n4n.ai)
- Basic familiarity with JSON serialization and HTTP
Defining the request structs
The chat completions endpoint expects a JSON body with model, messages, and optional sampling parameters. Map each field to a Go struct with explicit json tags so the marshaller produces exactly what the server expects.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature float32 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
}
The omitempty tag is critical: if you leave Temperature at its zero value, it is dropped from the wire format, letting the server apply its own default. Never send zero-value fields explicitly unless the API requires them.
Defining the response structs
The response nests choices under a top-level object and includes token accounting. Define the full tree so json.Unmarshal populates usage and finish reasons in one pass.
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 ChatCompletionResponse 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"`
}
These go structs openai compatible api match the documented wire format, so decoding is a single Decode call with no map lookups.
Building the HTTP client
Use net/http directly. A helper that sets the Authorization header and marshals the request keeps the call site clean.
func doChatCompletion(baseURL, apiKey string, req ChatCompletionRequest) (*ChatCompletionResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequest(http.MethodPost, baseURL+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var errBody map[string]any
json.NewDecoder(resp.Body).Decode(&errBody)
return nil, fmt.Errorf("status %d: %v", resp.StatusCode, errBody)
}
var completion ChatCompletionResponse
if err := json.NewDecoder(resp.Body).Decode(&completion); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &completion, nil
}
Testing against a live endpoint
Create a main.go that calls the function. Set the base URL to your provider. If you use n4n.ai, the same structs work against its OpenAI-compatible endpoint that addresses 240+ models.
func main() {
baseURL := os.Getenv("LLM_BASE_URL")
if baseURL == "" {
baseURL = "https://api.openai.com"
}
apiKey := os.Getenv("LLM_API_KEY")
if apiKey == "" {
log.Fatal("LLM_API_KEY required")
}
req := ChatCompletionRequest{
Model: "gpt-4o-mini",
Messages: []Message{
{Role: "system", Content: "You are a concise assistant."},
{Role: "user", Content: "What is the capital of France?"},
},
Temperature: 0.2,
}
resp, err := doChatCompletion(baseURL, apiKey, req)
if err != nil {
log.Fatalf("call failed: %v", err)
}
fmt.Printf("Model: %s\n", resp.Model)
fmt.Printf("Answer: %s\n", resp.Choices[0].Message.Content)
fmt.Printf("Usage: %+v\n", resp.Usage)
}
Run it:
export LLM_API_KEY=sk-...
export LLM_BASE_URL=https://api.openai.com
go run main.go
Expected output (truncated):
Model: gpt-4o-mini
Answer: The capital of France is Paris.
Usage: {PromptTokens:15 CompletionTokens:8 TotalTokens:23}
The go structs openai compatible api we defined decode cleanly; no manual map digging.
Handling errors and rate limits
OpenAI-compatible APIs return an error object with message and type. Extend your client to capture them with a typed struct instead of a generic map.
type APIError struct {
Message string `json:"message"`
Type string `json:"type"`
Param string `json:"param,omitempty"`
Code string `json:"code,omitempty"`
}
type ErrorResponse struct {
Error APIError `json:"error"`
}
Replace the error branch in doChatCompletion:
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
return nil, fmt.Errorf("status %d: decode error body: %w", resp.StatusCode, err)
}
return nil, fmt.Errorf("status %d: %s (%s)", resp.StatusCode, errResp.Error.Message, errResp.Error.Type)
}
If a provider is degraded, gateways often return 429 or 503. Your retry logic sits outside the structs; the decoding path stays identical.
Streaming responses with the same base
For token streaming, the server sends Server-Sent Events. The JSON payload per event is a slimmed variant where message becomes delta.
type Delta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
}
type StreamChoice struct {
Index int `json:"index"`
Delta Delta `json:"delta"`
FinishReason string `json:"finish_reason,omitempty"`
}
type ChatCompletionStreamResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []StreamChoice `json:"choices"`
}
Read the stream with a bufio.Scanner:
func doStream(baseURL, apiKey string, req ChatCompletionRequest) error {
req.Stream = true
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest(http.MethodPost, baseURL+"/v1/chat/completions", bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
payload := strings.TrimPrefix(line, "data: ")
if payload == "[DONE]" {
break
}
var chunk ChatCompletionStreamResponse
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
return err
}
if len(chunk.Choices) > 0 {
fmt.Print(chunk.Choices[0].Delta.Content)
}
}
fmt.Println()
return scanner.Err()
}
This reuses the same modeling discipline: the go structs openai compatible api for streaming are just another view of the same domain.
Extending for other endpoints
The pattern generalizes. For embeddings, define EmbeddingRequest with input as []string and EmbeddingResponse with data carrying vectors.
type EmbeddingRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
}
type Embedding struct {
Object string `json:"object"`
Index int `json:"index"`
Vector []float32 `json:"embedding"`
}
type EmbeddingResponse struct {
Data []Embedding `json:"data"`
Model string `json:"model"`
Usage Usage `json:"usage"`
}
Because the types are explicit, adding a new capability is a local change.
Takeaways
- Use explicit
jsontags on every field;omitemptyfor optionals. - Mirror the full response tree, including
usage, to get per-token metering without extra calls. - A minimal
net/httpclient with typed structs is enough for production calls to any OpenAI-compatible gateway. - Streaming is just a different response struct parsed line by line.