Go SDK
Official openai-go package with uttapen: client, chat, streaming with the accumulator and uttapen.meta, tool calling, embeddings, openai.Error handling.
Updated: September 7, 2026
The official github.com/openai/openai-go package (these examples were tested with v3) connects to uttapen with two options. Major versions use different module paths (/v2, /v3); the examples on this page are v3, and on v1 only the import and a few type names differ.
Install and client
go get github.com/openai/openai-go/v3
package main
import (
"context"
"fmt"
"os"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
)
func main() {
client := openai.NewClient(
option.WithBaseURL("https://api.uttapen.ir/v1"),
option.WithAPIKey(os.Getenv("UTTAPEN_API_KEY")), // sk-up-...
)
ctx := context.Background()
resp, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
Model: "openai/gpt-5-mini",
Messages: []openai.ChatCompletionMessageParamUnion{
openai.SystemMessage("Answer briefly."),
openai.UserMessage("What is the difference between a goroutine and a thread?"),
},
MaxTokens: openai.Int(300),
})
if err != nil {
panic(err)
}
fmt.Println(resp.Choices[0].Message.Content)
fmt.Println(resp.Usage.PromptTokens, resp.Usage.CompletionTokens)
}
option.WithMaxRetries, option.WithRequestTimeout and option.WithHeader (for a global X-Uttapen-Include-Meta) can be applied to the constructor or to a single call. OPENAI_BASE_URL and OPENAI_API_KEY are read automatically as well.
To read the cost headers off a non-streaming response:
var raw *http.Response
resp, err := client.Chat.Completions.New(ctx, params, option.WithResponseInto(&raw))
if err == nil {
fmt.Println(raw.Header.Get("X-Uttapen-Cost-Toman"), raw.Header.Get("X-Uttapen-Balance-Toman"))
}
Streaming
stream := client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{
Model: "openai/gpt-5-mini",
Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage("Write a Go function that validates an Iranian national ID.")},
}, option.WithHeader("X-Uttapen-Include-Meta", "1"))
acc := openai.ChatCompletionAccumulator{}
for stream.Next() {
chunk := stream.Current()
if chunk.Object == "uttapen.meta" {
fmt.Println("\nmeta:", chunk.RawJSON()) // cost_toman, balance_toman, hold_toman
continue
}
acc.AddChunk(chunk)
if len(chunk.Choices) > 0 {
fmt.Print(chunk.Choices[0].Delta.Content)
}
}
if err := stream.Err(); err != nil {
panic(err)
}
fmt.Println("\ncompletion tokens:", acc.Usage.CompletionTokens)
ChatCompletionAccumulator turns the chunks back into one complete ChatCompletion and stitches together the arguments of a streamed tool call. Filter the uttapen.meta event out before AddChunk so the accumulator is never handed a chunk without choices. Cancel a stream with context.WithCancel on ctx.
Tool calling
tools := []openai.ChatCompletionToolUnionParam{
openai.ChatCompletionFunctionTool(openai.FunctionDefinitionParam{
Name: "get_weather",
Description: openai.String("Current temperature of a city"),
Parameters: openai.FunctionParameters{
"type": "object",
"properties": map[string]any{"city": map[string]any{"type": "string"}},
"required": []string{"city"},
},
}),
}
msgs := []openai.ChatCompletionMessageParamUnion{openai.UserMessage("Weather in Tehran?")}
r1, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{Model: "openai/gpt-5-mini", Messages: msgs, Tools: tools})
if err != nil {
panic(err)
}
msgs = append(msgs, r1.Choices[0].Message.ToParam())
for _, call := range r1.Choices[0].Message.ToolCalls {
var args struct{ City string `json:"city"` }
_ = json.Unmarshal([]byte(call.Function.Arguments), &args)
result, _ := json.Marshal(map[string]any{"city": args.City, "temp_c": 31})
msgs = append(msgs, openai.ToolMessage(string(result), call.ID))
}
r2, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{Model: "openai/gpt-5-mini", Messages: msgs, Tools: tools})
if err != nil {
panic(err)
}
fmt.Println(r2.Choices[0].Message.Content)
Message.ToParam() hands the assistant message back as an input parameter with its tool_calls intact; without it the provider rejects the tool message that follows. Details in Tool calling.
Embeddings
emb, err := client.Embeddings.New(ctx, openai.EmbeddingNewParams{
Model: "openai/text-embedding-3-small",
Input: openai.EmbeddingNewParamsInputUnion{OfArrayOfStrings: []string{"lease agreement", "residential property rental contract"}},
})
if err != nil {
panic(err)
}
fmt.Println(len(emb.Data), len(emb.Data[0].Embedding), emb.Usage.PromptTokens)
For a single string, use OfString: openai.String("...").
Extra fields
Fields that have no place in the structs go into the body with option.WithJSONSet:
resp, err := client.Chat.Completions.New(ctx, params,
option.WithJSONSet("reasoning", map[string]any{"effort": "low"}),
option.WithJSONSet("models", []string{"openai/o3-mini"}),
)
Error handling
import "errors"
_, err := client.Chat.Completions.New(ctx, params)
var apierr *openai.Error
if errors.As(err, &apierr) {
switch apierr.StatusCode {
case 401:
log.Fatal("uttapen API key is invalid")
case 402:
log.Printf("insufficient balance: %s — %s", apierr.Message, apierr.RawJSON()) // the uttapen block is in RawJSON
case 429:
time.Sleep(5 * time.Second)
default:
log.Printf("%d %s: %s", apierr.StatusCode, apierr.Code, apierr.Message)
}
} else if err != nil {
log.Printf("network error: %v", err)
}
apierr.Code is our error.code, apierr.Request/apierr.Response are the raw request and response, and apierr.Response.Header.Get("X-Uttapen-Request-Id") is the request id. By default the SDK retries twice on 429 and 5xx. The full table is in Errors.
Notes
- Build the
contextwith a sensible timeout (reasoning models can run for minutes), and for streams usecontext.WithCancelso that when the user leaves, the upstream request is cancelled too. - In HTTP servers, call
http.Flusherafter every chunk, and setproxy_buffering offon your own nginx. - Toman amounts are strings; add them up with
shopspring/decimal, never withfloat64.
Concurrency and batching
To process thousands of records, bound your goroutines with a semaphore so you stay under the key's per-minute limit and can back off on 429 using Retry-After. errgroup makes collecting the errors easy.
import (
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
)
sem := semaphore.NewWeighted(8) // eight concurrent requests
g, gctx := errgroup.WithContext(ctx)
for _, doc := range docs {
doc := doc
g.Go(func() error {
if err := sem.Acquire(gctx, 1); err != nil {
return err
}
defer sem.Release(1)
resp, err := client.Chat.Completions.New(gctx, openai.ChatCompletionNewParams{
Model: "openai/gpt-5-nano",
Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage(doc.Text)},
MaxTokens: openai.Int(200),
})
if err != nil {
return err
}
doc.Summary = resp.Choices[0].Message.Content
return nil
})
}
if err := g.Wait(); err != nil {
log.Fatal(err)
}
With 8 goroutines and one-second responses you are issuing roughly 480 requests per minute, well above the default of 60; either raise the key's rate_limit_rpm or put a time.Ticker between requests. The first error returned cancels the whole group; if you want the rest to carry on, record the error inside the goroutine and return nil.
In an HTTP service
One openai.Client for the whole program is enough; it is thread-safe and owns its own connection pool. Pass each handler's context into the call, so that when the client goes away the request to uttapen is cancelled too and you are charged only for what was consumed. On shutdown, srv.Shutdown(ctx) closes those same contexts and open streams are cleaned up. Take X-Uttapen-Request-Id from option.WithResponseInto and write it into your structured log (slog) next to your own request id; when a user complains, that id finds their row in seconds.
Testing without spending
Test your own logic away from the network: put client behind a small interface (Complete(ctx, prompt) (string, error)) and pass a fake implementation in tests. For an integration test, stand up httptest.NewServer with a handler that returns JSON shaped like a chat/completions response and pass option.WithBaseURL(srv.URL + "/v1"); the SDK cannot tell it apart from uttapen. For end-to-end tests in CI, create a separate key with allowed_models limited to openai/gpt-5-nano and a small monthly cap; each test run costs a few toman and your main key stays out of CI. option.WithMiddleware is also handy for logging every request and response (without bodies) in one place.