n4nAI

Request and response structs for OpenAI-compatible APIs

Hands-on tutorial: define Go request and response structs for OpenAI-compatible APIs and build a net/http client with runnable code.

n4n Team2 min read473 words

Audio narration

Coming soon — every post will get a voice note here.

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 curl and go run available
  • 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 json tags on every field; omitempty for optionals.
  • Mirror the full response tree, including usage, to get per-token metering without extra calls.
  • A minimal net/http client with typed structs is enough for production calls to any OpenAI-compatible gateway.
  • Streaming is just a different response struct parsed line by line.
Tagsgolangstructsopenai-apijson

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All go net/http llm api client posts →