n4nAI

Parsing OpenAI JSON responses with encoding/json in Go

Step-by-step guide to go encoding/json openai response parsing in Go: define structs, call the API with net/http, and handle errors robustly.

n4n Team2 min read511 words

Audio narration

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

When you build a Go service that integrates with an LLM, the first rough edge is go encoding/json openai response parsing. The OpenAI REST API returns nested JSON with optional fields, usage metadata, and a shape that differs slightly between streaming and non-streaming modes. If you get the structs wrong, you silently drop tokens or panic on nil pointers.

Step 1: Define the response structs

Start from the actual JSON the API returns for a chat completion. A minimal successful response looks like this:

{
  "id": "chatcmpl-123",
  "object": "chat.completion",
  "created": 1699000000,
  "model": "gpt-4o-mini",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "The capital of France is Paris."
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 10,
    "completion_tokens": 5,
    "total_tokens": 15
  }
}

Map this to Go types using encoding/json tags. Use pointers for fields that may be omitted in streaming or error cases.

type ChatCompletion 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,omitempty"`
}

type Choice struct {
	Index        int     `json:"index"`
	Message      Message `json:"message"`
	FinishReason *string `json:"finish_reason,omitempty"`
}

type Message struct {
	Role    string  `json:"role"`
	Content *string `json:"content,omitempty"`
}

type Usage struct {
	PromptTokens     int `json:"prompt_tokens"`
	CompletionTokens int `json:"completion_tokens"`
	TotalTokens      int `json:"total_tokens"`
}

The omitempty and pointer types matter. In streaming chunks, usage is often absent, and content may be null when a tool call is returned instead.

Step 2: Send a request with net/http

Build the request body the same way you parse responses: a Go struct marshaled to JSON. Use net/http directly; the standard library is enough.

type chatRequest struct {
	Model    string   `json:"model"`
	Messages []Message `json:"messages"`
}

func complete(apiKey, model, prompt string) (*ChatCompletion, error) {
	reqBody := chatRequest{
		Model: model,
		Messages: []Message{
			{Role: "user", Content: &prompt},
		},
	}
	b, err := json.Marshal(reqBody)
	if err != nil {
		return nil, err
	}

	// An OpenAI-compatible endpoint works here. n4n.ai exposes one
	// OpenAI-compatible endpoint across 240+ models with automatic
	// fallback when a provider is degraded, so the same code runs unchanged.
	req, err := http.NewRequest("POST", "https://api.openai.com/v1/chat/completions", bytes.NewReader(b))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+apiKey)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	return decodeResponse(resp)
}

Step 3: Parse the response with encoding/json

Never call json.Unmarshal on resp.Body without checking the status code. A 429 or 500 returns a different JSON envelope. Separate decoding logic:

func decodeResponse(resp *http.Response) (*ChatCompletion, error) {
	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: %w", resp.StatusCode, err)
		}
		return nil, fmt.Errorf("API error: %s: %s", errResp.Error.Type, errResp.Error.Message)
	}

	var comp ChatCompletion
	if err := json.NewDecoder(resp.Body).Decode(&comp); err != nil {
		return nil, fmt.Errorf("decode completion: %w", err)
	}
	return &comp, nil
}

type ErrorResponse struct {
	Error struct {
		Message string `json:"message"`
		Type    string `json:"type"`
		Code    string `json:"code"`
	} `json:"error"`
}

Using json.NewDecoder instead of json.Unmarshal avoids reading the whole body into a []byte first, which matters when responses include large completions.

Step 4: Handle streaming chunks

If you set "stream": true, the API returns newline-delimited data: frames. Each frame is a JSON object with a choices array where delta replaces message.

type StreamChunk struct {
	ID      string `json:"id"`
	Object  string `json:"object"`
	Created int64  `json:"created"`
	Model   string `json:"model"`
	Choices []struct {
		Index        int     `json:"index"`
		Delta        Message `json:"delta"`
		FinishReason *string `json:"finish_reason,omitempty"`
	} `json:"choices"`
}

Read the stream with bufio.Scanner, skip the data: prefix, and break on [DONE].

func streamComplete(apiKey, model, prompt string) error {
	// build request with Stream: true omitted for brevity
	resp, err := http.DefaultClient.Do(req)
	// ... error checks ...
	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 StreamChunk
		if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
			return err
		}
		for _, c := range chunk.Choices {
			if c.Delta.Content != nil {
				fmt.Print(*c.Delta.Content)
			}
		}
	}
	return scanner.Err()
}

The delta message uses the same Message struct; content is a pointer so you can detect when it’s missing.

Step 5: Defend against schema drift

OpenAI occasionally adds fields like system_fingerprint or extends finish_reason values. Your structs should ignore unknown fields by default—encoding/json does this—but you must avoid strict decoding. Do not use DisallowUnknownFields() in production clients unless you control both ends.

For nullable string content, always check the pointer before dereferencing:

if len(comp.Choices) > 0 {
	content := comp.Choices[0].Message.Content
	if content != nil {
		fmt.Println(*content)
	}
}

If you need usage metering, note that usage is only present in the final streaming chunk when stream_options: {"include_usage": true} is set. Handle it as a pointer and check before reading.

Step 6: Verify success

Write a main that calls complete with a trivial prompt and prints the result. Run it with your API key in the environment.

func main() {
	key := os.Getenv("OPENAI_API_KEY")
	if key == "" {
		log.Fatal("set OPENAI_API_KEY")
	}
	comp, err := complete(key, "gpt-4o-mini", "What is the capital of France?")
	if err != nil {
		log.Fatal(err)
	}
	if len(comp.Choices) == 0 {
		log.Fatal("no choices returned")
	}
	fmt.Println(*comp.Choices[0].Message.Content)
}

Verify by cross-checking with a raw curl call:

curl https://api.openai.com/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"What is the capital of France?"}]}'

If both outputs contain “Paris” and the usage.total_tokens field is populated, your go encoding/json openai response parsing is correct. For streaming, pipe the output and confirm tokens arrive incrementally without JSON parse errors.

Edge cases worth coding for

  • Rate limits: a 429 returns error.code: "rate_limit_exceeded". Back off using Retry-After header.
  • Moderation hits: some models return finish_reason: "content_filter" with empty content. Check FinishReason before assuming text.
  • Tool calls: message may contain tool_calls instead of content. Extend Message with a ToolCalls slice if you use functions.
  • Gateway routing: if you front the API with a gateway that honors client routing directives, the response model field may differ from your request. Trust the response, not the request.

Getting go encoding/json openai response parsing right means modeling the optional bits as pointers and separating error envelopes from success envelopes. Do that, and your Go client will survive schema updates and partial responses without surprises.

Tagsgolangjsonopenai-apiencoding-json

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 →