Build a Model Arena CLI with KodeKey

Call Claude, GPT, and Gemini through one API endpoint using Go.

Introduction

30 Second Summary

Every major AI provider has its own API, its own authentication, its own request format. If you want to try Claude, GPT, and Gemini, you normally need three different setups. A unified API gateway solves this by giving you one endpoint that routes to all of them.

In this project, you will build a command-line tool in Go that sends prompts to multiple AI models through KodeKloud's KodeKey unified API gateway and displays their responses side by side in your terminal.

What You'll Build

You type a question into your terminal, pick which models to battle, and watch Claude, GPT, and Gemini race to answer. All through one URL, one API key, and one request format you built yourself.

By the end of this project, you'll have:

  • A working CLI where you type a question and get an AI-generated answer printed straight to your terminal from a model of your choice.
  • A parallel arena mode that fires the same prompt at multiple models simultaneously and shows their answers with timing data, so you can compare how different providers respond.
  • A multi-turn conversation mode where the AI remembers what you said earlier, proving that chat memory is just a growing list of messages you resend each time.
  • Secret Mission: A "judge" mode where one AI model rates the quality of the other models' responses.

Are there any prerequisites?

You should be comfortable with basic Go syntax: variables, functions, structs, and loops. You also need a KodeKloud Pro account for KodeKey access. The project walks you through generating your API key in Step 1.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

This is your moment to commit to the goal. Once you're clear on the destination, every step that follows has a purpose.

Set Up Your Project

Before your Go program can talk to any AI model, it needs two things: a project structure to hold your code, and an API key that authenticates your requests.

In this step, you'll generate a KodeKey API key from your KodeKloud account and create a Go project that verifies the key is loaded.

In this step, get ready to:
  • Generate your KodeKey API key and store it as an environment variable.
  • Create a Go project with a main function that confirms the key is ready.
Generate your KodeKey API key

KodeKey is KodeKloud's unified API gateway. It gives you a single endpoint and a single API key to access multiple AI models from different providers.

Why save it somewhere safe?

Most API key providers show the key only once at the moment of creation. If you lose it, you'll need to generate a new one.

Now store the key as an environment variable so your Go program can read it at runtime.

  • Open a terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Set your API key by running this command (replace your-key-here with the key you just copied):

macOS

export KODEKEY_API_KEY="your-key-here"

Windows

$env:KODEKEY_API_KEY = "your-key-here"
  • Confirm the variable is set by running:

macOS

echo $KODEKEY_API_KEY

Windows

echo $env:KODEKEY_API_KEY

You should see your API key printed back to the terminal.

Seeing a blank line instead of your key?

Make sure you ran the export command in the same terminal session. If you opened a new terminal window, the variable is not carried over.

Also check that you did not accidentally include extra spaces around the = sign in the export command.

Help me troubleshoot my KODEKEY_API_KEY environment variable not being set.

Create your Go project
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir model-arena
cd model-arena
  • Initialize the Go module by running:
go mod init model-arena

You should see a confirmation message that the module was created.

What is a Go module?

A Go module is the standard way to manage dependencies in a Go project. The go.mod file it creates tracks your module name and Go version. Even though this project uses only the standard library, Go still requires a module to build your code.

  • Confirm go.mod exists by running:
ls

You should see go.mod listed in the output.

Write the entry point

Your program needs a starting point that checks whether the API key is available before doing anything else.

  • In VS Code, open the model-arena folder. Click File in the top menu bar, then Open Folder, and select the model-arena folder on your Desktop.
  • Create a new file called main.go by clicking File then New File. Name it main.go and press Enter.
  • Add the following code to main.go:
package main

import (
	"fmt"
	"os"
)

func main() {
	// Read the API key from the environment
	apiKey := os.Getenv("KODEKEY_API_KEY")
	if apiKey == "" {
		fmt.Println("Error: KODEKEY_API_KEY environment variable is not set.")
		fmt.Println("Get your key at: https://learn.kodekloud.com/user/playgrounds/kodekey")
		os.Exit(1)
	}

	// Confirm the key is loaded and the program is ready
	fmt.Println("Model Arena ready!")
}

What does this code do?

  • os.Getenv("KODEKEY_API_KEY") reads the environment variable you just exported. If it is missing, the function returns an empty string.
  • The if apiKey == "" check catches the empty case and exits early with a helpful error message so you know exactly what is wrong.
  • If the key exists, the program prints Model Arena ready! to confirm everything is wired up.
  • Save main.go by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Before you run this, what do you expect to see? If your environment variable is set correctly, the program should confirm it is ready. If not, it should tell you what is missing.

  • Run your program by running this command in the VS Code terminal:
go run main.go

You should see Model Arena ready! printed to your terminal.

Seeing the KODEKEY_API_KEY error instead?

If you see the error message about the environment variable not being set, your terminal session may have changed. Make sure you are running go run main.go in the same terminal where you ran the export command earlier.

If you opened a new terminal panel in VS Code, re-run the export command there first, then try go run main.go again.

Help me fix the KODEKEY_API_KEY not being found when I run my Go program.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and that you see Model Arena ready! in your terminal output.

ⓧ I'd like to double check the full code

Here is the complete main.go file you should have at this point:

package main

import (
	"fmt"
	"os"
)

func main() {
	apiKey := os.Getenv("KODEKEY_API_KEY")
	if apiKey == "" {
		fmt.Println("Error: KODEKEY_API_KEY environment variable is not set.")
		fmt.Println("Get your key at: https://learn.kodekloud.com/user/playgrounds/kodekey")
		os.Exit(1)
	}

	fmt.Println("Model Arena ready!")
}

Your environment is wired up and your program can confirm it has access to the API key. Next up, you'll use that key to make your first real AI call and see a model respond directly in your terminal.

Make Your First AI Call

Your Go project compiles and your API key is ready. Now it is time to use them.

Before you can compare models or build a conversation, you need to understand what actually happens when you talk to an AI. The answer: you send an HTTP POST request with a JSON body, and you get a JSON response back. That is all there is to it.

In this step, get ready to:
  • Define Go structs that match the chat completions API format.
  • Write a function that builds and sends an HTTP POST request to KodeKey.
  • Get your first AI-generated response printed in the terminal.
Define the request and response structs

The KodeKey API uses the OpenAI chat completions format. Your Go code needs structs that mirror the shape of the JSON you send and the JSON you receive.

  • In main.go, find the existing import block:
import (
	"fmt"
	"os"
)
  • Replace it with this expanded import block that includes the packages you need for HTTP requests and JSON handling:
import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

What are these new packages?

  • bytes lets you wrap JSON data into a request body.
  • encoding/json converts Go structs to and from JSON.
  • io reads the raw bytes from an HTTP response.
  • net/http is Go's built-in HTTP client for making web requests.
  • Now add the API endpoint constant and your four structs below the import block and above func main():
const baseURL = "https://api.ai.kodekloud.com/v1/chat/completions"

// Structs matching the OpenAI chat completions API format
type Message struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type ChatRequest struct {
	Model     string    `json:"model"`
	Messages  []Message `json:"messages"`
	MaxTokens int       `json:"max_tokens"`
}

type Choice struct {
	Index   int     `json:"index"`
	Message Message `json:"message"`
}

type ChatResponse struct {
	Choices []Choice `json:"choices"`
}

What do these structs represent?

  • Message is one message in a conversation. Each message has a Role (like "user" or "assistant") and Content (the text).
  • ChatRequest is the JSON body you send to the API. It names which model to use and the messages to process.
  • ChatResponse and Choice mirror the JSON the API sends back. The AI's answer lives inside the first choice's message content.

The json:"..." tags tell Go how to map each struct field to a JSON key.

Write the callModel function

This function does the heavy lifting: it takes a model name and a list of messages, sends them to KodeKey, and returns the AI's text response.

  • Add the first half of the callModel function below your struct definitions (still above func main()). This part builds the HTTP request:
func callModel(apiKey string, model string, messages []Message) (string, error) {
	// Build the request body
	reqBody := ChatRequest{
		Model:     model,
		Messages:  messages,
		MaxTokens: 500,
	}

	// Convert the struct to JSON
	jsonData, err := json.Marshal(reqBody)
	if err != nil {
		return "", fmt.Errorf("failed to marshal request: %w", err)
	}

	// Create the HTTP POST request
	req, err := http.NewRequest("POST", baseURL, bytes.NewBuffer(jsonData))
	if err != nil {
		return "", fmt.Errorf("failed to create request: %w", err)
	}

	// Set required headers
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+apiKey)

What does this code do?

  • json.Marshal(reqBody) converts your Go struct into the JSON bytes that the API expects.
  • http.NewRequest("POST", ...) creates a request object without sending it yet. The JSON goes in the body.
  • The two Header.Set calls add the Content-Type (telling the API you are sending JSON) and Authorization (your Bearer token) headers.
  • Still inside the same callModel function, add the code that executes the request and parses the response. Paste this directly below the Authorization header line:
	// Execute the request
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("request failed: %w", err)
	}
	defer resp.Body.Close()

	// Read the response body
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("failed to read response: %w", err)
	}

	// Check for non-200 status codes
	if resp.StatusCode != 200 {
		return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
	}

	// Parse the JSON response
	var chatResp ChatResponse
	if err := json.Unmarshal(body, &chatResp); err != nil {
		return "", fmt.Errorf("failed to parse response: %w", err)
	}

	if len(chatResp.Choices) == 0 {
		return "", fmt.Errorf("no choices in response")
	}

	return chatResp.Choices[0].Message.Content, nil
}

What does this second half do?

  • http.DefaultClient.Do(req) fires the request over the network and waits for the response.
  • io.ReadAll(resp.Body) reads every byte the API sent back into a byte slice.
  • The status code check catches errors from the gateway (wrong model name, bad key, rate limits) and returns the raw error message.
  • json.Unmarshal converts the JSON bytes into your ChatResponse struct so you can access the AI's answer as a Go string.
  • Save main.go.
  • Verify the file compiles by running:
go build .

If you see no output, the build succeeded. Go prints nothing on a successful compile.

Seeing a compile error?

  • If you see "unexpected EOF" or "syntax error: unexpected }", check that both halves of callModel are pasted together as one continuous function with a single closing } at the end.
  • If you see "imported and not used", make sure the callModel function is present. It uses all the new imports.

Still stuck? Help me fix a Go compile error in my callModel function

Call the model from main

Your function is ready but nothing calls it yet. Time to wire it up and see an AI respond.

  • In main.go, find this line inside your main function:
	fmt.Println("Model Arena ready!")
  • Add the following code directly below that line to call Claude Haiku and print its response:
	// Send a test prompt to Claude Haiku
	messages := []Message{
		{Role: "user", Content: "What is a unified API gateway in one sentence?"},
	}
	response, err := callModel(apiKey, "claude-haiku-4-5", messages)
	if err != nil {
		fmt.Printf("Error: %v\n", err)
		return
	}
	fmt.Println(response)

What does this code do?

  • You create a messages slice with one user message containing your prompt.
  • callModel sends that prompt to the claude-haiku-4-5 model through the KodeKey gateway and returns the AI's text.
  • If something goes wrong (network error, bad key, invalid model), the error gets printed and the program exits early.
  • Save main.go.

Before you run this, what do you think will happen? You are sending an HTTP POST with a JSON body to a remote server. Will you get a one-sentence answer, an error, or something else?

  • Run your program:
go run main.go

You should see "Model Arena ready!" followed by a one-sentence explanation of what a unified API gateway is, written by Claude Haiku. The exact wording varies each time because AI responses are non-deterministic.

Seeing an API error?

  • If you see "KODEKEY_API_KEY environment variable is not set", re-export the variable in your current terminal session. Environment variables do not persist across terminal restarts.
  • If you see "API error (status 401)", your key may be incorrect. Double-check it at https://learn.kodekloud.com/user/playgrounds/kodekey.
  • If you see a network timeout, check your internet connection. The request needs to reach KodeKey's servers.

Help me debug a KodeKey API error in my Go program

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete main.go at this point. Scroll through and confirm each section is present in order: imports, const, structs, callModel, then main.

  • The import block includes bytes, encoding/json, fmt, io, net/http, and os.
  • The baseURL constant points to https://api.ai.kodekloud.com/v1/chat/completions.
  • Four structs: Message, ChatRequest, Choice, ChatResponse.
  • The callModel function marshals, sends, reads, checks status, and unmarshals.
  • The main function reads the key, prints "Model Arena ready!", then calls callModel and prints the response.

You just made a raw HTTP call to an AI model with zero SDKs and zero external packages. Next up, you will switch to different AI providers by changing a single string. You will also find out what happens when a model name does not exist.

Discover the Gateway Advantage

You have working code that calls Claude and prints a response. Now imagine you want the same answer from GPT or Gemini. With direct API access, you would need a different endpoint URL, different authentication credentials, and probably a different SDK for each provider.

A unified API gateway eliminates all of that. You change one string in your code: the model name. The URL stays the same. The auth header stays the same. The JSON format stays the same. Let's prove it by calling three models with identical code.

In this step, get ready to:
  • Send the same prompt to GPT, Gemini, and an invalid model through the gateway.
  • See the gateway's error handling in action when a model doesn't exist.
  • Format output with model labels for easy side-by-side comparison.
Replace your single call with a multi-model loop

Your current code calls one model and exits. To demonstrate the gateway's power, you'll define a list of models and loop through them. Each iteration sends the same prompt with the same request format. Only the model string changes.

  • In main.go, find your import block and add "strings" after "os".
  • Then find this code in your main function (everything after the "Model Arena ready!" line):
messages := []Message{
	{Role: "user", Content: "What is a unified API gateway in one sentence?"},
}
response, err := callModel(apiKey, "claude-haiku-4-5", messages)
if err != nil {
	fmt.Printf("Error: %v\n", err)
	return
}
fmt.Println(response)
  • Replace that code with the following multi-model loop:
// Define which models to query - including one fake to test error handling
models := []string{"gpt-5.5", "google/gemini-3.5-flash", "fake-model-99"}
prompt := "What is a unified API gateway in one sentence?"

// Loop through each model with the same prompt and request format
for _, model := range models {
	fmt.Println(strings.Repeat("-", 40))
	fmt.Printf("Model: %s\n", model)
	messages := []Message{
		{Role: "user", Content: prompt},
	}
	response, err := callModel(apiKey, model, messages)
	if err != nil {
		fmt.Printf("Error: %v\n", err)
	} else {
		fmt.Println(response)
	}
}

What does this code do?

  • models defines a slice with three model names: two real providers (GPT and Gemini) and one intentionally invalid name.
  • prompt stores the question so the exact same text is sent to every model.
  • The for loop calls callModel with the same API key, same URL, and same request format each time. Only the model string changes.
  • strings.Repeat("-", 40) prints a visual separator line between results so you can easily distinguish each model's output.
  • Save main.go.
Run the program and observe the gateway in action

Before you run this, think about what will happen with fake-model-99. Will the program crash? Will you get an error message? What would that error tell you about how the gateway works?

  • Run your program by executing this command:
go run main.go

You should see three sections of output. The first two models (gpt-5.5 and google/gemini-3.5-flash) each return a real answer about API gateways. The third (fake-model-99) returns an error message from the gateway.

What does this prove about the gateway?

Look at the two successful responses. GPT is made by OpenAI. Gemini is made by Google. These are completely different companies with completely different APIs. Yet your code is character-for-character identical for both calls.

The error for fake-model-99 is equally revealing. The gateway received your request, tried to route it to a provider, and returned a structured error explaining the model doesn't exist. The gateway is doing the routing and validation for you.

Seeing errors for all three models?

  • Check that your KODEKEY_API_KEY environment variable is still set. Run echo $KODEKEY_API_KEY to verify it has a value.
  • Make sure you added "strings" to your import block. Without it, Go will throw a compile error before the program even runs.
  • Verify that the model names are exactly gpt-5.5 and google/gemini-3.5-flash with no typos. The gateway is case-sensitive.

Help me debug errors from all three KodeKey models in my Go program.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.go and that your output shows two real responses plus one error.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these pieces is present in your main.go, in order:

  • Your import block includes "strings" alongside the existing imports (bytes, encoding/json, fmt, io, net/http, os).
  • The baseURL constant, all four structs (Message, ChatRequest, Choice, ChatResponse), and the callModel function are unchanged from Step 2.
  • Your main function has the API key check, the "Model Arena ready!" print, the models slice with three entries, and the for loop that prints labeled results.

Why this matters for real-world tools

You just accessed Claude, GPT, and Gemini through one URL with one API key. This is exactly how tools like AI Fiesta and T3 Chat work under the hood. They sit behind a unified gateway so their frontend code only needs one integration path, regardless of how many AI providers they support.

You've proven the gateway works for multiple providers. But calling them one by one is slow. Next up, you'll fire all requests in parallel using goroutines and build a real model arena where they race to answer.

Build the Arena with Parallel Calls

In the previous step, you proved that a unified API gateway gives you access to multiple AI providers through identical code. The only thing that changed between calling GPT, Gemini, and a fake model was the model string.

Calling models one at a time is slow. If each model takes 2 seconds to respond, three models take 6 seconds total. Go's goroutines let you fire all requests simultaneously and collect results as they arrive. This is exactly how real multi-model tools fan out requests in parallel to minimize wait time.

In this step, get ready to:
  • Create a Result struct and a compareModels function that launches goroutines in parallel.
  • Format and display each model's response with timing information.
  • Wire up user input so you can type any prompt and watch the models race.
Create the Result struct and compareModels function

Each goroutine needs somewhere to store its outcome: the model name, the response text, how long it took, and whether it errored. A dedicated struct captures all of that cleanly.

  • In main.go, find your import block at the top of the file. It currently looks like this:
import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)
  • Add "sync" and "time" to the import block so it looks like this:
import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"
)
  • Below your ChatResponse struct, add the Result struct by pasting this code:
type Result struct {
	Model    string
	Response string
	Duration time.Duration
	Err      error
}

What does this struct hold?

  • Model records which model produced this result.
  • Duration captures how long the API call took, so you can compare speed.
  • Err stores any error that occurred, so one failed model does not crash the whole arena.
  • Below the callModel function, add the compareModels function by pasting this code:
func compareModels(apiKey string, prompt string, models []string) []Result {
	results := make([]Result, len(models))
	var wg sync.WaitGroup

	for i, model := range models {
		wg.Add(1)
		go func(idx int, m string) {
			defer wg.Done()
			start := time.Now()
			messages := []Message{
				{Role: "user", Content: prompt},
			}
			response, err := callModel(apiKey, m, messages)
			results[idx] = Result{
				Model:    m,
				Response: response,
				Duration: time.Since(start),
				Err:      err,
			}
		}(i, model)
	}

	wg.Wait()
	return results
}

What does this code do?

  • results := make([]Result, len(models)) pre-allocates a slice with one slot per model. Each goroutine writes to its own index, so there is no race condition.
  • wg.Add(1) increments a counter before each goroutine launches. defer wg.Done() decrements it when the goroutine finishes.
  • wg.Wait() blocks until all goroutines have called Done(). Only then does the function return the fully populated results slice.
  • time.Now() and time.Since(start) measure how long each individual model takes to respond.
  • Save main.go.
  • Confirm everything compiles by running this command:
go build .

You should see no output at all. Silence means the Go compiler is happy with your goroutine code.

Seeing a compile error?

  • If you see imported and not used for sync or time, make sure you pasted the compareModels function (which uses both packages).
  • If you see undefined: Result, check that the Result struct is above the compareModels function in the file.
  • help me fix my Go compile error in main.go
Format the output with printResults

The arena needs a clear display so you can scan across model responses quickly. This function formats each result with the model name, response time, and the actual text.

  • Below your compareModels function, add the printResults function by pasting this code:
func printResults(results []Result) {
	fmt.Println("\n" + strings.Repeat("=", 60))
	fmt.Println("  MODEL ARENA RESULTS")
	fmt.Println(strings.Repeat("=", 60))

	for _, r := range results {
		fmt.Printf("\n--- %s (%.1fs) ---\n", r.Model, r.Duration.Seconds())
		if r.Err != nil {
			fmt.Printf("  ERROR: %v\n", r.Err)
		} else {
			fmt.Printf("%s\n", r.Response)
		}
	}
	fmt.Println(strings.Repeat("=", 60))
}

What does this code do?

  • strings.Repeat("=", 60) draws visual separators so each model's output stands apart.
  • %.1fs formats the duration as seconds with one decimal place (e.g. 2.3s).
  • The if r.Err != nil check ensures that a failed model prints its error instead of crashing the whole display.
  • Save main.go and confirm it still compiles by running:
go build .

No output means success. Your arena functions are ready. Next, you will connect them to user input.

Connect user input to the arena

Right now, your main function still runs the old sequential loop with a hardcoded prompt. You will replace it with an interactive version that reads the user's prompt and fires it at all three models in parallel.

  • In your import block, add "bufio" on a new line after the opening parenthesis. Your full import block should now be:
import (
	"bufio"
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"
)
  • Replace your entire main function (from func main() { to its closing }) with this code:
func main() {
	apiKey := os.Getenv("KODEKEY_API_KEY")
	if apiKey == "" {
		fmt.Println("Error: KODEKEY_API_KEY environment variable is not set.")
		fmt.Println("Get your key at: https://learn.kodekloud.com/user/playgrounds/kodekey")
		os.Exit(1)
	}

	fmt.Println("Model Arena ready!")

	models := []string{"claude-haiku-4-5", "gpt-5.5", "google/gemini-3.5-flash"}

	scanner := bufio.NewScanner(os.Stdin)
	fmt.Print("\nEnter your prompt: ")
	if !scanner.Scan() {
		return
	}
	prompt := scanner.Text()

	if strings.TrimSpace(prompt) == "" {
		fmt.Println("Empty prompt, exiting.")
		return
	}

	fmt.Println("\nSending to all models in parallel...")
	results := compareModels(apiKey, prompt, models)
	printResults(results)
}

What changed in main?

  • The hardcoded prompt and sequential loop are gone. Now bufio.NewScanner(os.Stdin) reads whatever you type at runtime.
  • The models slice now uses claude-haiku-4-5, gpt-5.5, and google/gemini-3.5-flash. Three real providers, one prompt, fired in parallel.
  • compareModels handles all the concurrent work. printResults handles all the formatting. Your main stays clean and readable.
  • Save main.go.

Before you run the program, think about this: you are sending the exact same prompt to three different AI providers in parallel. Do you expect all three to respond at roughly the same speed, or will some be noticeably faster?

  • Run the arena by executing:
go run main.go
  • When you see Enter your prompt:, type a question like Explain goroutines in one sentence and press **Enter**.

You should see three responses appear with timing data next to each model name. Notice how the total time is close to the slowest individual model, not the sum of all three. That is parallelism in action.

Not seeing any output?

  • If you see an API error for all three models, confirm your KODEKEY_API_KEY environment variable is still set in your current terminal session. Run echo $KODEKEY_API_KEY to check.
  • If the program hangs indefinitely, your network may be blocking outbound HTTPS requests. Try from a different network or check your firewall.
  • If you see a compile error mentioning bufio, confirm you added "bufio" to your import block.
  • help me debug my Model Arena parallel calls

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and saw responses from all three models with timing information.

ⓧ I'd like to double check the full code

Your main.go should contain these sections in order. Scroll up and confirm each one is present:

  • The import block with all 10 packages (bufio, bytes, encoding/json, fmt, io, net/http, os, strings, sync, time).
  • The Message, ChatRequest, Choice, ChatResponse, and Result structs.
  • The callModel, compareModels, and printResults functions.
  • The updated main function that reads user input with bufio.Scanner and calls compareModels then printResults.

Why WaitGroup instead of channels?

Go offers channels for goroutine communication, but sync.WaitGroup is simpler when you have a fixed number of results and just need to wait for all of them. Each goroutine writes directly to a pre-allocated slice index, so there is no ordering complexity.

Channels shine when goroutines produce results at unpredictable rates and you want to process them as they arrive. For a fixed set of parallel API calls, WaitGroup is the idiomatic choice.

Your arena is live. You can pit three AI models against each other on any question and see who responds fastest. Next up, you will add multi-turn conversations where the AI remembers what you said earlier.

Add Multi-Turn Conversations

Your arena can fire prompts at models and show their answers. But a single prompt-response exchange is limited. Real AI applications maintain context across multiple turns of conversation.

The chat completions API achieves this by sending the full conversation history in every request. "Memory" in AI chat is not magic. It is the client's job to accumulate messages and resend them each time. In this step, you will build that mechanism yourself.

In this step, get ready to:
  • Create a Conversation struct that accumulates message history.
  • Build a chat REPL that maintains context across turns.
  • Add a mode selector so users can choose between arena and chat.
Create the Conversation struct

To send the full conversation history with every request, you need a data structure that accumulates messages. Each time the user speaks, you append their message. Each time the AI responds, you append that too. The next API call includes everything.

  • In main.go, find the Result struct. Add the following code directly below it:
// Conversation maintains message history for multi-turn chat
type Conversation struct {
	Messages []Message
}

func (c *Conversation) AddUser(content string) {
	c.Messages = append(c.Messages, Message{Role: "user", Content: content})
}

func (c *Conversation) AddAssistant(content string) {
	c.Messages = append(c.Messages, Message{Role: "assistant", Content: content})
}

What does this code do?

The Conversation struct holds a growing slice of Message values. Each message has a Role ("user" or "assistant") and Content.

AddUser appends what the human said. AddAssistant appends what the AI replied. When you pass conv.Messages to callModel, the API sees the entire conversation and can respond in context.

  • Save main.go.
  • Confirm your code compiles by running:
go build

No output means the code compiled successfully.

Seeing a compile error?

Check that your Conversation struct is placed AFTER the Result struct and BEFORE the callModel function. Make sure both methods use a pointer receiver *Conversation with an uppercase C.

Help me fix my Conversation struct compile error.

Build the chat REPL and extract runArena

Now you need two separate functions: one for the arena mode you already built, and a new one for multi-turn chat. You will extract the existing arena logic into its own function, then create the chat function.

  • In main.go, find the printResults function. Add the following runArena function directly below it:
func runArena(apiKey string, scanner *bufio.Scanner) {
	models := []string{"claude-haiku-4-5", "gpt-5.5", "google/gemini-3.5-flash"}

	fmt.Println("\nMODEL ARENA - Compare AI models head to head!")
	fmt.Println("Models:", strings.Join(models, ", "))
	fmt.Print("\nEnter your prompt: ")

	if !scanner.Scan() {
		return
	}
	prompt := scanner.Text()

	if strings.TrimSpace(prompt) == "" {
		fmt.Println("Empty prompt, exiting arena.")
		return
	}

	fmt.Println("\nSending to all models in parallel...")
	results := compareModels(apiKey, prompt, models)
	printResults(results)
}

What does this code do?

This extracts the arena logic from main into its own function. It takes the API key and a scanner (for reading input), prompts the user, then calls compareModels and printResults as before. Now main can choose which function to call.

  • Now add the runChat function directly below runArena:
func runChat(apiKey string, scanner *bufio.Scanner) {
	model := "claude-haiku-4-5"
	conv := &Conversation{}

	fmt.Printf("\nCHAT MODE - Talking to %s\n", model)
	fmt.Println("Type 'quit' to exit.\n")
	for {
		fmt.Print("You: ")
		if !scanner.Scan() {
			break
		}
		input := scanner.Text()
		if strings.TrimSpace(strings.ToLower(input)) == "quit" {
			fmt.Println("Goodbye!")
			break
		}
		if strings.TrimSpace(input) == "" {
			continue
		}
		conv.AddUser(input)
		response, err := callModel(apiKey, model, conv.Messages)
		if err != nil {
			fmt.Printf("Error: %v\n\n", err)
			// Remove the failed user message from history
			conv.Messages = conv.Messages[:len(conv.Messages)-1]
			continue
		}
		conv.AddAssistant(response)
		fmt.Printf("\nAI: %s\n\n", response)
	}
}

What does this code do?

  • It creates a Conversation and enters an infinite loop that reads user input.
  • Each user message is added via AddUser, then the ENTIRE conv.Messages slice is sent to the API. This is how the model "remembers" what you said earlier.
  • If the API call fails, the user message is removed from history so the conversation stays clean.
  • On success, the assistant's response is appended via AddAssistant so the next turn includes it.
  • Save main.go and run go build to confirm both new functions compile.
go build

No output means success. You now have both modes as functions, ready to be wired up.

Getting "declared and not used" errors?

Go will complain if your old main function still references local variables for the arena logic that you moved into runArena. You will replace main in the next substep. If the error blocks you, move on to the mode selector below which replaces main entirely.

Help me fix my Go compile errors after adding runArena and runChat.

Add the mode selector

The final piece is a new main function that presents a menu and routes the user to either arena or chat mode.

  • In main.go, find your existing func main() function. Delete its entire body (everything between the opening and closing braces).
  • Replace it with this new main function:
func main() {
	apiKey := os.Getenv("KODEKEY_API_KEY")
	if apiKey == "" {
		fmt.Println("Error: KODEKEY_API_KEY environment variable is not set.")
		fmt.Println("Get your key at: https://learn.kodekloud.com/user/playgrounds/kodekey")
		os.Exit(1)
	}

	fmt.Println("Model Arena ready!")
	fmt.Println("\nChoose a mode:")
	fmt.Println("  1. arena - Compare multiple models on the same prompt")
	fmt.Println("  2. chat  - Multi-turn conversation with one model")
	fmt.Print("\nEnter mode (arena/chat): ")

	scanner := bufio.NewScanner(os.Stdin)
	if !scanner.Scan() {
		return
	}
	mode := strings.TrimSpace(strings.ToLower(scanner.Text()))

	switch mode {
	case "1", "arena":
		runArena(apiKey, scanner)
	case "2", "chat":
		runChat(apiKey, scanner)
	default:
		fmt.Println("Unknown mode. Please enter 'arena' or 'chat'.")
	}
}

What does this code do?

  • It checks the API key, then prints a mode menu with two options.
  • The switch accepts either the number ("1"/"2") or the word ("arena"/"chat") for flexibility.
  • The same scanner is passed to both functions so they can read subsequent user input without creating a new one.
  • Save main.go.

Before you run this, what do you think will happen if you type "My name is Alex" and then ask "What is my name?" in chat mode? Will the model remember?

  • Test multi-turn memory by running:
go run main.go
  • When prompted, type chat and press Enter.
  • Type My name is Alex and press Enter. Wait for the AI to respond.
  • Type What is my name? and press Enter.

The model correctly recalls "Alex" from the previous turn. That is your conversation history at work. Every request sends the full Messages slice, so the model sees everything you said before.

  • Type quit to exit chat mode.

Why does this work?

After your first message, conv.Messages contains one user message and one assistant reply. When you ask "What is my name?", the API receives both previous messages PLUS your new question. The model reads the full history and answers accordingly.

This is exactly how ChatGPT, Claude, and every other chat interface works under the hood. There is no server-side memory. The client sends everything every time.

Model not remembering your name?

Make sure runChat passes conv.Messages (the full slice) to callModel, not just the latest message. If you only send the current input, the model has no history to reference.

Also confirm that AddAssistant is called after a successful response. Without it, the model's reply is lost between turns.

Help me debug why my chat mode is not maintaining conversation history.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and both modes work (arena and chat).

ⓧ I'd like to double check the full code

Your main.go should contain these sections in order. Scroll up and confirm each is present:

  • Package declaration and imports (bufio, bytes, encoding/json, fmt, io, net/http, os, strings, sync, time).
  • baseURL constant.
  • Structs: Message, ChatRequest, Choice, ChatResponse, Result, Conversation.
  • AddUser and AddAssistant methods on *Conversation.
  • Functions: callModel, compareModels, printResults, runArena, runChat, main.
  • main presents a mode menu and uses a switch to route to runArena or runChat.

You now have a complete CLI with two modes: a parallel arena for comparing models, and a multi-turn chat that proves conversation memory is just the client sending a growing list of messages. Next up, you will push this further with a Secret Mission that adds a judge to your arena.

Secret mission

The Judge

Your arena shows multiple models answering the same question. But who answered best? Add a "judge" mode where a more capable model evaluates and rates the other models' responses on clarity, accuracy, and completeness.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no cloud resources provisioned. Your KodeKey API credits are part of your existing KodeKloud Pro subscription.

Credit awareness

Your KodeKloud Pro plan includes 25 credits per month, with 500 tokens per credit. Each API call to KodeKey consumes 1 credit regardless of which model you use. Credits renew on the 1st of every month.

Resources you used:

  • The model-arena project directory on your Desktop (contains main.go and go.mod).
  • The KODEKEY_API_KEY environment variable exported in your terminal session.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with new models and prompts.

  • Your code is local and free to keep. It takes up minimal disk space.
  • Your KodeKey API key remains active as part of your KodeKloud Pro subscription.
  • The KODEKEY_API_KEY environment variable only persists in your current terminal session (unless you added it to your shell profile).

Pause - I'll come back to this later

Remove the API key from your environment to prevent accidental credit usage. Your project files stay on disk for when you return.

  • Unset the environment variable in your current terminal session by running:
unset KODEKEY_API_KEY
  • If you added the export line to your shell profile (~/.zshrc or ~/.bashrc), remove that line as well.
  • Your model-arena folder remains on your Desktop. You can re-export the key and run the project again anytime.

Delete - I don't want to use this again

Remove all project files and the environment variable from your system.

  • Unset the environment variable by running:
unset KODEKEY_API_KEY
  • Delete the project directory by running:
rm -rf ~/Desktop/model-arena
  • If you added the export line to your shell profile (~/.zshrc or ~/.bashrc), remove that line.
  • Your KodeKey API key remains active on your KodeKloud account. No separate action is needed to deactivate it.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional Model Arena CLI in Go that talks to multiple AI providers through a single unified API gateway.

You've learned how to:

  • Build a Go CLI that makes raw HTTP POST requests to AI models without any SDK or external dependency, constructing every header and JSON byte yourself.
  • Prove that a unified API gateway lets you access Claude, GPT, Gemini, and DeepSeek through one URL, one key, and one request format.
  • Use goroutines to call multiple models in parallel and implement multi-turn conversation memory by accumulating the messages array across turns.
  • Secret Mission: Build a judge system where one AI model evaluates and rates the responses of other models on clarity, accuracy, and completeness.

Ready to quiz yourself?