├── .github
├── FUNDING.yml
└── workflows
│ └── go.yml
├── marshaller.go
├── go.mod
├── .gitignore
├── examples
├── embeddings
│ └── main.go
├── completion
│ └── main.go
├── structured
│ └── main.go
├── structured-deepseek
│ └── main.go
└── completion-tool
│ └── main.go
├── go.sum
├── common.go
├── request_builder.go
├── config.go
├── CONTRIBUTING.md
├── pdf_helper.go
├── request_builder_test.go
├── audio_helper.go
├── jsonschema
├── validate.go
├── json.go
├── validate_test.go
└── json_test.go
├── messages.go
├── embeddings_test.go
├── stream_test.go
├── generation.go
├── models.go
├── error.go
├── embeddings.go
├── client.go
├── chat_test.go
├── README.md
├── completion.go
├── sample_prompt_test.go
├── client_test.go
├── LICENSE
└── chat.go
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | ko_fi: revrost
2 |
--------------------------------------------------------------------------------
/marshaller.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import (
4 | "encoding/json"
5 | )
6 |
7 | type Marshaller interface {
8 | Marshal(value any) ([]byte, error)
9 | }
10 |
11 | type JSONMarshaller struct{}
12 |
13 | func (jm *JSONMarshaller) Marshal(value any) ([]byte, error) {
14 | return json.Marshal(value)
15 | }
16 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/revrost/go-openrouter
2 |
3 | go 1.23
4 |
5 | toolchain go1.23.5
6 |
7 | require github.com/stretchr/testify v1.10.0
8 |
9 | require (
10 | github.com/davecgh/go-spew v1.1.1 // indirect
11 | github.com/pmezard/go-difflib v1.0.0 // indirect
12 | gopkg.in/yaml.v3 v3.0.1 // indirect
13 | )
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # If you prefer the allow list template instead of the deny list, see community template:
2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3 | #
4 | # Binaries for programs and plugins
5 | *.exe
6 | *.exe~
7 | *.dll
8 | *.so
9 | *.dylib
10 |
11 | # Test binary, built with `go test -c`
12 | *.test
13 |
14 | # Output of the go coverage tool, specifically when used with LiteIDE
15 | *.out
16 |
17 | # Dependency directories (remove the comment below to include it)
18 | # vendor/
19 |
20 | # Go workspace file
21 | go.work
22 | go.work.sum
23 |
24 | # env file
25 | .env
26 | /.idea/
27 |
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a golang project
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
3 |
4 | name: Go
5 |
6 | on:
7 | push:
8 | branches: [ "main" ]
9 | pull_request:
10 | branches: [ "main" ]
11 |
12 | jobs:
13 |
14 | build:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v4
18 |
19 | - name: Set up Go
20 | uses: actions/setup-go@v4
21 | with:
22 | go-version: '1.23'
23 |
24 | - name: Build
25 | run: go build -v ./...
26 |
27 | - name: Test
28 | run: go test -v ./...
29 |
--------------------------------------------------------------------------------
/examples/embeddings/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "os"
8 |
9 | "github.com/revrost/go-openrouter"
10 | )
11 |
12 | func main() {
13 | ctx := context.Background()
14 | client := openrouter.NewClient(os.Getenv("OPENROUTER_API_KEY"))
15 |
16 | // Basic text embedding example
17 | request := openrouter.EmbeddingsRequest{
18 | Model: "openai/text-embedding-3-large",
19 | Input: []string{
20 | "Hello world",
21 | "OpenRouter embeddings example",
22 | },
23 | EncodingFormat: openrouter.EmbeddingsEncodingFormatFloat,
24 | }
25 |
26 | res, err := client.CreateEmbeddings(ctx, request)
27 | if err != nil {
28 | fmt.Println("error", err)
29 | return
30 | }
31 |
32 | b, _ := json.MarshalIndent(res, "", "\t")
33 | fmt.Printf("response :\n %s\n", string(b))
34 | }
35 |
36 |
37 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
11 |
--------------------------------------------------------------------------------
/common.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | // Usage Represents the total token usage per request to OpenAI.
4 | type Usage struct {
5 | PromptTokens int `json:"prompt_tokens"`
6 | CompletionTokens int `json:"completion_tokens"`
7 | CompletionTokenDetails CompletionTokenDetails `json:"completion_token_details"`
8 | TotalTokens int `json:"total_tokens"`
9 |
10 | Cost float64 `json:"cost"`
11 | CostDetails CostDetails `json:"cost_details"`
12 |
13 | PromptTokenDetails PromptTokenDetails `json:"prompt_token_details"`
14 | }
15 |
16 | type CostDetails struct {
17 | UpstreamInferenceCost float64 `json:"upstream_inference_cost"`
18 | }
19 |
20 | type CompletionTokenDetails struct {
21 | ReasoningTokens int `json:"reasoning_tokens"`
22 | }
23 |
24 | type PromptTokenDetails struct {
25 | CachedTokens int `json:"cached_tokens"`
26 | }
27 |
--------------------------------------------------------------------------------
/examples/completion/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "os"
8 |
9 | "github.com/revrost/go-openrouter"
10 | )
11 |
12 | func main() {
13 | ctx := context.Background()
14 | client := openrouter.NewClient(os.Getenv("OPENROUTER_API_KEY"))
15 | request := openrouter.ChatCompletionRequest{
16 | Model: openrouter.DeepseekV3,
17 | Messages: []openrouter.ChatCompletionMessage{
18 | {
19 | Role: openrouter.ChatMessageRoleSystem,
20 | Content: openrouter.Content{Text: "You are a helfpul assistant."},
21 | },
22 | {
23 | Role: openrouter.ChatMessageRoleUser,
24 | Content: openrouter.Content{Text: "Hello!"},
25 | },
26 | },
27 | Stream: false,
28 | }
29 |
30 | res, err := client.CreateChatCompletion(ctx, request)
31 | if err != nil {
32 | fmt.Println("error", err)
33 | } else {
34 | b, _ := json.MarshalIndent(res, "", "\t")
35 | fmt.Printf("request :\n %s", string(b))
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/request_builder.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "io"
7 | "net/http"
8 | )
9 |
10 | type RequestBuilder interface {
11 | Build(ctx context.Context, method, url string, body any, header http.Header) (*http.Request, error)
12 | }
13 |
14 | type HTTPRequestBuilder struct {
15 | marshaller Marshaller
16 | }
17 |
18 | func NewRequestBuilder() *HTTPRequestBuilder {
19 | return &HTTPRequestBuilder{
20 | marshaller: &JSONMarshaller{},
21 | }
22 | }
23 |
24 | func (b *HTTPRequestBuilder) Build(
25 | ctx context.Context,
26 | method string,
27 | url string,
28 | body any,
29 | header http.Header,
30 | ) (req *http.Request, err error) {
31 | var bodyReader io.Reader
32 | if body != nil {
33 | if v, ok := body.(io.Reader); ok {
34 | bodyReader = v
35 | } else {
36 | var reqBytes []byte
37 | reqBytes, err = b.marshaller.Marshal(body)
38 | if err != nil {
39 | return
40 | }
41 | bodyReader = bytes.NewBuffer(reqBytes)
42 | }
43 | }
44 | req, err = http.NewRequestWithContext(ctx, method, url, bodyReader)
45 | if err != nil {
46 | return
47 | }
48 | if header != nil {
49 | req.Header = header
50 | }
51 | return
52 | }
53 |
--------------------------------------------------------------------------------
/config.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import "net/http"
4 |
5 | // ClientConfig is a configuration for the openrouter client.
6 | type ClientConfig struct {
7 | authToken string
8 |
9 | BaseURL string
10 | OrgID string
11 | AssistantVersion string
12 | HTTPClient HTTPDoer
13 | HttpReferer string
14 | XTitle string
15 |
16 | EmptyMessagesLimit uint
17 | }
18 |
19 | type HTTPDoer interface {
20 | Do(req *http.Request) (*http.Response, error)
21 | }
22 |
23 | const defaultEmptyMessagesLimit = 10
24 |
25 | func DefaultConfig(authToken string) *ClientConfig {
26 | return &ClientConfig{
27 | authToken: authToken,
28 | XTitle: "",
29 | HttpReferer: "",
30 | BaseURL: "https://openrouter.ai/api/v1",
31 | AssistantVersion: "",
32 | OrgID: "",
33 |
34 | HTTPClient: &http.Client{},
35 |
36 | EmptyMessagesLimit: defaultEmptyMessagesLimit,
37 | }
38 | }
39 |
40 | type Option func(*ClientConfig)
41 |
42 | func WithXTitle(title string) Option {
43 | return func(c *ClientConfig) {
44 | c.XTitle = title
45 | }
46 | }
47 |
48 | func WithHTTPReferer(referer string) Option {
49 | return func(c *ClientConfig) {
50 | c.HttpReferer = referer
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to go-openrouter 🤖
2 |
3 | Welcome! We appreciate your interest in improving this unofficial Go client for OpenRouter.
4 |
5 | ## Getting Started 🚀
6 |
7 | 1. **Install Go** (1.20+ recommended)
8 | 2. **Fork** the repository
9 | 3. **Clone** your fork:
10 | ```bash
11 | git clone https://github.com/your-username/go-openrouter.git
12 | ```
13 |
14 | # Contribution Guidelines 📝
15 |
16 | # Before You Code
17 |
18 | Check existing issues/pull requests
19 |
20 | Open an issue first for significant changes
21 |
22 | # Development Flow
23 |
24 | Create a feature branch:
25 |
26 | bash
27 | Copy
28 | git checkout -b feat/your-feature-name
29 | Follow Go conventions:
30 |
31 | Use gofmt
32 |
33 | Include tests for new features
34 |
35 | Add documentation for public symbols
36 |
37 | Write clear commit messages using Conventional Commits
38 |
39 | # Testing ✅
40 |
41 | Run tests:
42 |
43 | bash
44 | Copy
45 | go test -v ./...
46 | Include integration tests for API calls (use test credentials)
47 |
48 | # Submitting Changes 📬
49 |
50 | Push your branch
51 |
52 | Create a Pull Request against main
53 |
54 | Include:
55 |
56 | Description of changes
57 |
58 | Related issues
59 |
60 | Test results
61 |
62 | Any caveats
63 |
64 | # Code of Conduct 🏛️
65 |
66 | Be excellent to each other! Follow the Contributor Covenant.
67 |
68 | # Acknowledgements 🙌
69 |
70 | All contributors will be recognized in our CREDITS.md file.
71 |
72 | This is an unofficial project not affiliated with OpenRouter. Let's build something great together! 🚀
73 |
--------------------------------------------------------------------------------
/pdf_helper.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import (
4 | "os"
5 | "strings"
6 | )
7 |
8 | // CreatePDFPlugin creates a completion plugin to process PDFs using the specified engine.
9 | // The engine can be: "mistral-ocr" (for scanned documents/PDFs with images),
10 | // "pdf-text" (for well-structured PDFs - free), or "native" (only for models that support file input).
11 | func CreatePDFPlugin(engine PDFEngine) ChatCompletionPlugin {
12 | return ChatCompletionPlugin{
13 | ID: PluginIDFileParser,
14 | PDF: &PDFPlugin{
15 | Engine: string(engine),
16 | },
17 | }
18 | }
19 |
20 | // UserMessageWithPDFFromFile creates a user message with text and PDF content from a file.
21 | // It reads the PDF file and creates a message with the embedded PDF data.
22 | func UserMessageWithPDFFromFile(text, filePath string) (ChatCompletionMessage, error) {
23 | fileData, err := os.ReadFile(filePath)
24 | if err != nil {
25 | return ChatCompletionMessage{}, err
26 | }
27 |
28 | filename := filePath
29 | if idx := strings.LastIndex(filePath, "\\"); idx != -1 {
30 | filename = filePath[idx+1:]
31 | }
32 | if idx := strings.LastIndex(filename, "/"); idx != -1 {
33 | filename = filename[idx+1:]
34 | }
35 |
36 | return ChatCompletionMessage{
37 | Role: ChatMessageRoleUser,
38 | Content: Content{
39 | Multi: []ChatMessagePart{
40 | {
41 | Type: ChatMessagePartTypeText,
42 | Text: text,
43 | },
44 | {
45 | Type: ChatMessagePartTypeFile,
46 | File: &FileContent{
47 | Filename: filename,
48 | FileData: string(fileData),
49 | },
50 | },
51 | },
52 | },
53 | }, nil
54 | }
55 |
--------------------------------------------------------------------------------
/examples/structured/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "log"
8 | "os"
9 |
10 | "github.com/revrost/go-openrouter"
11 | "github.com/revrost/go-openrouter/jsonschema"
12 | )
13 |
14 | func main() {
15 | ctx := context.Background()
16 | client := openrouter.NewClient(os.Getenv("OPENROUTER_API_KEY"))
17 |
18 | type Result struct {
19 | Location string `json:"location"`
20 | Temperature float64 `json:"temperature"`
21 | Condition string `json:"condition"`
22 | }
23 | var result Result
24 | schema, err := jsonschema.GenerateSchemaForType(result)
25 | if err != nil {
26 | log.Fatalf("GenerateSchemaForType error: %v", err)
27 | }
28 |
29 | request := openrouter.ChatCompletionRequest{
30 | Model: openrouter.DeepseekV3,
31 | Messages: []openrouter.ChatCompletionMessage{
32 | {
33 | Role: openrouter.ChatMessageRoleUser,
34 | Content: openrouter.Content{Text: "What's the weather like in London?"},
35 | },
36 | },
37 | ResponseFormat: &openrouter.ChatCompletionResponseFormat{
38 | Type: openrouter.ChatCompletionResponseFormatTypeJSONSchema,
39 | JSONSchema: &openrouter.ChatCompletionResponseFormatJSONSchema{
40 | Name: "weather",
41 | Schema: schema,
42 | Strict: true,
43 | },
44 | },
45 | }
46 |
47 | pj, _ := json.MarshalIndent(request, "", "\t")
48 | fmt.Printf("request :\n %s\n", string(pj))
49 |
50 | res, err := client.CreateChatCompletion(ctx, request)
51 | if err != nil {
52 | fmt.Println("error", err)
53 | } else {
54 | b, _ := json.MarshalIndent(res, "", "\t")
55 | fmt.Printf("response :\n %s", string(b))
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/examples/structured-deepseek/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "log"
8 | "os"
9 |
10 | "github.com/revrost/go-openrouter"
11 | )
12 |
13 | func main() {
14 | ctx := context.Background()
15 | client := openrouter.NewClient(os.Getenv("OPENROUTER_API_KEY"))
16 |
17 | type Result struct {
18 | Location string `json:"location"`
19 | Temperature float64 `json:"temperature"`
20 | Condition string `json:"condition"`
21 | }
22 | result := Result{
23 | Location: "London",
24 | Temperature: 20.0,
25 | Condition: "Sunny",
26 | }
27 | jsonString, err := json.Marshal(result)
28 | if err != nil {
29 | log.Fatalf("GenerateSchemaForType error: %v", err)
30 | }
31 |
32 | request := openrouter.ChatCompletionRequest{
33 | Model: openrouter.DeepseekV3,
34 | Messages: []openrouter.ChatCompletionMessage{
35 | {
36 | Role: openrouter.ChatMessageRoleSystem,
37 | Content: openrouter.Content{Text: "EXAMPLE JSON OUTPUT: " + string(jsonString)},
38 | },
39 | {
40 | Role: openrouter.ChatMessageRoleUser,
41 | Content: openrouter.Content{Text: "What's the weather like in London?"},
42 | },
43 | },
44 | ResponseFormat: &openrouter.ChatCompletionResponseFormat{
45 | Type: openrouter.ChatCompletionResponseFormatTypeJSONObject,
46 | },
47 | }
48 |
49 | pj, _ := json.MarshalIndent(request, "", "\t")
50 | fmt.Printf("request :\n %s\n", string(pj))
51 |
52 | res, err := client.CreateChatCompletion(ctx, request)
53 | if err != nil {
54 | fmt.Println("error", err)
55 | } else {
56 | b, _ := json.MarshalIndent(res, "", "\t")
57 | fmt.Printf("response :\n %s", string(b))
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/request_builder_test.go:
--------------------------------------------------------------------------------
1 | package openrouter //nolint:testpackage // testing private field
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "errors"
7 | "net/http"
8 | "reflect"
9 | "testing"
10 | )
11 |
12 | var errTestMarshallerFailed = errors.New("test marshaller failed")
13 |
14 | type failingMarshaller struct{}
15 |
16 | func (*failingMarshaller) Marshal(_ any) ([]byte, error) {
17 | return []byte{}, errTestMarshallerFailed
18 | }
19 |
20 | func TestRequestBuilderReturnsMarshallerErrors(t *testing.T) {
21 | builder := HTTPRequestBuilder{
22 | marshaller: &failingMarshaller{},
23 | }
24 |
25 | _, err := builder.Build(context.Background(), "", "", struct{}{}, nil)
26 | if !errors.Is(err, errTestMarshallerFailed) {
27 | t.Fatalf("Did not return error when marshaller failed: %v", err)
28 | }
29 | }
30 |
31 | func TestRequestBuilderReturnsRequest(t *testing.T) {
32 | b := NewRequestBuilder()
33 | var (
34 | ctx = context.Background()
35 | method = http.MethodPost
36 | url = "/foo"
37 | request = map[string]string{"foo": "bar"}
38 | reqBytes, _ = b.marshaller.Marshal(request)
39 | want, _ = http.NewRequestWithContext(ctx, method, url, bytes.NewBuffer(reqBytes))
40 | )
41 | got, _ := b.Build(ctx, method, url, request, nil)
42 | if !reflect.DeepEqual(got.Body, want.Body) ||
43 | !reflect.DeepEqual(got.URL, want.URL) ||
44 | !reflect.DeepEqual(got.Method, want.Method) {
45 | t.Errorf("Build() got = %v, want %v", got, want)
46 | }
47 | }
48 |
49 | func TestRequestBuilderReturnsRequestWhenRequestOfArgsIsNil(t *testing.T) {
50 | var (
51 | ctx = context.Background()
52 | method = http.MethodGet
53 | url = "/foo"
54 | want, _ = http.NewRequestWithContext(ctx, method, url, nil)
55 | )
56 | b := NewRequestBuilder()
57 | got, _ := b.Build(ctx, method, url, nil, nil)
58 | if !reflect.DeepEqual(got, want) {
59 | t.Errorf("Build() got = %v, want %v", got, want)
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/audio_helper.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import (
4 | "encoding/base64"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 | )
10 |
11 | // UserMessageWithAudioFromFile creates a user message with the given prompt text and audio file.
12 | // It reads the audio file (mp3 or wav) and creates a message with the embedded audio data.
13 | func UserMessageWithAudioFromFile(promptText, filePath string) (ChatCompletionMessage, error) {
14 | fileData, err := os.ReadFile(filePath)
15 | if err != nil {
16 | return ChatCompletionMessage{}, err
17 | }
18 |
19 | ext := filepath.Ext(filePath)
20 | var format AudioFormat
21 | switch strings.ToLower(ext) {
22 | case ".mp3":
23 | format = AudioFormatMp3
24 | case ".wav":
25 | format = AudioFormatWav
26 | default:
27 | return ChatCompletionMessage{}, fmt.Errorf("unsupported audio format: %s", ext)
28 | }
29 |
30 | msg := UserMessageWithAudio(promptText, fileData, format)
31 |
32 | return msg, nil
33 | }
34 |
35 | // UserMessageWithAudio creates a user message with the given prompt text and audio content.
36 | // Creates a message with the embedded audio data.
37 | func UserMessageWithAudio(promptText string, audio []byte, format AudioFormat) ChatCompletionMessage {
38 | msg := ChatCompletionMessage{
39 | Role: ChatMessageRoleUser,
40 | Content: Content{
41 | Multi: []ChatMessagePart{
42 | {
43 | Type: ChatMessagePartTypeText,
44 | Text: promptText,
45 | },
46 | chatMessagePartWithAudio(audio, format),
47 | },
48 | },
49 | }
50 |
51 | return msg
52 | }
53 |
54 | // chatMessagePartWithAudio creates a ChatMessagePart which contains the given audio content.
55 | func chatMessagePartWithAudio(audio []byte, format AudioFormat) ChatMessagePart {
56 | audioEncoded := base64.StdEncoding.EncodeToString(audio)
57 |
58 | msg := ChatMessagePart{
59 | Type: ChatMessagePartTypeInputAudio,
60 | InputAudio: &ChatMessageInputAudio{
61 | Format: format,
62 | Data: audioEncoded,
63 | },
64 | }
65 |
66 | return msg
67 | }
68 |
--------------------------------------------------------------------------------
/jsonschema/validate.go:
--------------------------------------------------------------------------------
1 | package jsonschema
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | )
7 |
8 | func VerifySchemaAndUnmarshal(schema Definition, content []byte, v any) error {
9 | var data any
10 | err := json.Unmarshal(content, &data)
11 | if err != nil {
12 | return err
13 | }
14 | if !Validate(schema, data) {
15 | return errors.New("data validation failed against the provided schema")
16 | }
17 | return json.Unmarshal(content, &v)
18 | }
19 |
20 | func Validate(schema Definition, data any) bool {
21 | switch schema.Type {
22 | case Object:
23 | return validateObject(schema, data)
24 | case Array:
25 | return validateArray(schema, data)
26 | case String:
27 | _, ok := data.(string)
28 | return ok
29 | case Number: // float64 and int
30 | _, ok := data.(float64)
31 | if !ok {
32 | _, ok = data.(int)
33 | }
34 | return ok
35 | case Boolean:
36 | _, ok := data.(bool)
37 | return ok
38 | case Integer:
39 | // Golang unmarshals all numbers as float64, so we need to check if the float64 is an integer
40 | if num, ok := data.(float64); ok {
41 | return num == float64(int64(num))
42 | }
43 | _, ok := data.(int)
44 | return ok
45 | case Null:
46 | return data == nil
47 | default:
48 | return false
49 | }
50 | }
51 |
52 | func validateObject(schema Definition, data any) bool {
53 | dataMap, ok := data.(map[string]any)
54 | if !ok {
55 | return false
56 | }
57 | for _, field := range schema.Required {
58 | if _, exists := dataMap[field]; !exists {
59 | return false
60 | }
61 | }
62 | for key, valueSchema := range schema.Properties {
63 | value, exists := dataMap[key]
64 | if exists && !Validate(valueSchema, value) {
65 | return false
66 | } else if !exists && contains(schema.Required, key) {
67 | return false
68 | }
69 | }
70 | return true
71 | }
72 |
73 | func validateArray(schema Definition, data any) bool {
74 | dataArray, ok := data.([]any)
75 | if !ok {
76 | return false
77 | }
78 | for _, item := range dataArray {
79 | if !Validate(*schema.Items, item) {
80 | return false
81 | }
82 | }
83 | return true
84 | }
85 |
86 | func contains[S ~[]E, E comparable](s S, v E) bool {
87 | for i := range s {
88 | if v == s[i] {
89 | return true
90 | }
91 | }
92 | return false
93 | }
94 |
--------------------------------------------------------------------------------
/messages.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | // SystemMessage creates a new system message with the given text content.
4 | func SystemMessage(content string) ChatCompletionMessage {
5 | return ChatCompletionMessage{
6 | Role: ChatMessageRoleSystem,
7 | Content: Content{
8 | Text: content,
9 | },
10 | }
11 | }
12 |
13 | // UserMessage creates a new user message with the given text content.
14 | func UserMessage(content string) ChatCompletionMessage {
15 | return ChatCompletionMessage{
16 | Role: ChatMessageRoleUser,
17 | Content: Content{
18 | Text: content,
19 | },
20 | }
21 | }
22 |
23 | // AssistantMessage creates a new assistant message with the given text content.
24 | func AssistantMessage(content string) ChatCompletionMessage {
25 | return ChatCompletionMessage{
26 | Role: ChatMessageRoleAssistant,
27 | Content: Content{
28 | Text: content,
29 | },
30 | }
31 | }
32 |
33 | // ToolMessage creates a new tool (response) message with a call ID and content.
34 | func ToolMessage(callID string, content string) ChatCompletionMessage {
35 | return ChatCompletionMessage{
36 | Role: ChatMessageRoleTool,
37 | Content: Content{
38 | Text: content,
39 | },
40 | ToolCallID: callID,
41 | }
42 | }
43 |
44 | // UserMessageWithPDF creates a new user message with text and PDF file content.
45 | func UserMessageWithPDF(text, filename, fileData string) ChatCompletionMessage {
46 | return ChatCompletionMessage{
47 | Role: ChatMessageRoleUser,
48 | Content: Content{
49 | Multi: []ChatMessagePart{
50 | {
51 | Type: ChatMessagePartTypeText,
52 | Text: text,
53 | },
54 | {
55 | Type: ChatMessagePartTypeFile,
56 | File: &FileContent{
57 | Filename: filename,
58 | FileData: fileData,
59 | },
60 | },
61 | },
62 | },
63 | }
64 | }
65 |
66 | // UserMessageWithImage creates a new user message with text and image URL.
67 | func UserMessageWithImage(text, imageURL string) ChatCompletionMessage {
68 | return ChatCompletionMessage{
69 | Role: ChatMessageRoleUser,
70 | Content: Content{
71 | Multi: []ChatMessagePart{
72 | {
73 | Type: ChatMessagePartTypeText,
74 | Text: text,
75 | },
76 | {
77 | Type: ChatMessagePartTypeImageURL,
78 | ImageURL: &ChatMessageImageURL{
79 | URL: imageURL,
80 | },
81 | },
82 | },
83 | },
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/embeddings_test.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import (
4 | "context"
5 | "io"
6 | "net/http"
7 | "strings"
8 | "testing"
9 |
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | type fakeHTTPClient struct {
14 | lastRequest *http.Request
15 | response *http.Response
16 | err error
17 | }
18 |
19 | func (f *fakeHTTPClient) Do(req *http.Request) (*http.Response, error) {
20 | f.lastRequest = req
21 | if f.err != nil {
22 | return nil, f.err
23 | }
24 | return f.response, nil
25 | }
26 |
27 | func TestCreateEmbeddings_Basic(t *testing.T) {
28 | body := `{
29 | "id": "embd_123",
30 | "object": "list",
31 | "data": [
32 | {
33 | "object": "embedding",
34 | "embedding": [0.1, 0.2, 0.3],
35 | "index": 0
36 | }
37 | ],
38 | "model": "test-embeddings-model",
39 | "usage": {
40 | "prompt_tokens": 5,
41 | "total_tokens": 5,
42 | "cost": 0.0001
43 | }
44 | }`
45 |
46 | fakeClient := &fakeHTTPClient{
47 | response: &http.Response{
48 | StatusCode: http.StatusOK,
49 | Body: io.NopCloser(strings.NewReader(body)),
50 | Header: make(http.Header),
51 | },
52 | }
53 |
54 | cfg := DefaultConfig("test-token")
55 | cfg.BaseURL = "https://example.com/api/v1"
56 | cfg.HTTPClient = fakeClient
57 |
58 | client := NewClientWithConfig(*cfg)
59 |
60 | req := EmbeddingsRequest{
61 | Model: "test-embeddings-model",
62 | Input: "hello world",
63 | }
64 |
65 | resp, err := client.CreateEmbeddings(context.Background(), req)
66 | require.NoError(t, err)
67 |
68 | require.NotNil(t, fakeClient.lastRequest)
69 | require.Equal(t, http.MethodPost, fakeClient.lastRequest.Method)
70 | require.True(t, strings.HasSuffix(fakeClient.lastRequest.URL.Path, "/embeddings"))
71 |
72 | require.Equal(t, "embd_123", resp.ID)
73 | require.Equal(t, "list", resp.Object)
74 | require.Equal(t, "test-embeddings-model", resp.Model)
75 | require.NotNil(t, resp.Usage)
76 | require.Equal(t, 5, resp.Usage.PromptTokens)
77 | require.Len(t, resp.Data, 1)
78 | require.Len(t, resp.Data[0].Embedding.Vector, 3)
79 | }
80 |
81 | func TestEmbeddingValue_UnmarshalJSON_Base64(t *testing.T) {
82 | var v EmbeddingValue
83 |
84 | err := v.UnmarshalJSON([]byte(`"dGVzdC1lbWJlZGRpbmc="`))
85 | require.NoError(t, err)
86 | require.Nil(t, v.Vector)
87 | require.Equal(t, "dGVzdC1lbWJlZGRpbmc=", v.Base64)
88 | }
89 |
90 |
91 |
--------------------------------------------------------------------------------
/stream_test.go:
--------------------------------------------------------------------------------
1 | package openrouter_test
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "io"
9 | "testing"
10 |
11 | "log/slog"
12 |
13 | openrouter "github.com/revrost/go-openrouter"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | // Test streaming with reasoning
18 | func TestChatCompletionMessageMarshalJSON_StreamingWithReasoning(t *testing.T) {
19 | t.Skip("Only run this test locally")
20 | client := createTestClient(t)
21 |
22 | stream, err := client.CreateChatCompletionStream(
23 | context.Background(), openrouter.ChatCompletionRequest{
24 | Reasoning: &openrouter.ChatCompletionReasoning{
25 | Effort: openrouter.String("high"),
26 | },
27 | Model: "google/gemini-2.5-pro-preview",
28 | Messages: []openrouter.ChatCompletionMessage{
29 | {
30 | Role: "user",
31 | Content: openrouter.Content{Text: "Help me think whether i should make coffee with sugar ?"},
32 | },
33 | },
34 | Stream: true,
35 | },
36 | )
37 | require.NoError(t, err)
38 | defer stream.Close()
39 |
40 | for {
41 | response, err := stream.Recv()
42 | if err != nil && err != io.EOF {
43 | require.NoError(t, err)
44 | }
45 | if errors.Is(err, io.EOF) {
46 | fmt.Println("EOF, stream finished")
47 | return
48 | }
49 | b, err := json.MarshalIndent(response, "", " ")
50 | require.NoError(t, err)
51 | fmt.Println(string(b))
52 | }
53 | }
54 |
55 | // Test streaming
56 | func TestChatCompletionMessageMarshalJSON_Streaming(t *testing.T) {
57 | client := createTestClient(t)
58 |
59 | stream, err := client.CreateChatCompletionStream(
60 | context.Background(), openrouter.ChatCompletionRequest{
61 | Model: FreeModel,
62 | Messages: []openrouter.ChatCompletionMessage{
63 | {
64 | Role: "user",
65 | Content: openrouter.Content{Text: "Help me think whether i should make coffee with sugar ?"},
66 | },
67 | },
68 | Stream: true,
69 | },
70 | )
71 | require.NoError(t, err)
72 | defer stream.Close()
73 |
74 | for {
75 | response, err := stream.Recv()
76 | if err != nil && err != io.EOF {
77 | require.NoError(t, err)
78 | }
79 | if errors.Is(err, io.EOF) {
80 | fmt.Println("EOF, stream finished")
81 | return
82 | }
83 | b, err := json.MarshalIndent(response, "", " ")
84 | require.NoError(t, err)
85 | slog.Debug(string(b))
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/generation.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "net/url"
7 | )
8 |
9 | const (
10 | getGenerationSuffix = "/generation"
11 | )
12 |
13 | type Generation struct {
14 | ID string `json:"id"`
15 | TotalCost float64 `json:"total_cost"`
16 | CreatedAt string `json:"created_at"`
17 | Model string `json:"model"`
18 | Origin string `json:"origin"`
19 | Usage float64 `json:"usage"`
20 | IsBYOK bool `json:"is_byok"`
21 | UpstreamID *string `json:"upstream_id,omitempty"`
22 | CacheDiscount *float64 `json:"cache_discount,omitempty"`
23 | UpstreamInferenceCost *float64 `json:"upstream_inference_cost,omitempty"`
24 | AppID *int `json:"app_id,omitempty"`
25 | Streamed *bool `json:"streamed,omitempty"`
26 | Cancelled *bool `json:"cancelled,omitempty"`
27 | ProviderName *string `json:"provider_name,omitempty"`
28 | Latency *int `json:"latency,omitempty"`
29 | ModerationLatency *int `json:"moderation_latency,omitempty"`
30 | GenerationTime *int `json:"generation_time,omitempty"`
31 | FinishReason *string `json:"finish_reason,omitempty"`
32 | NativeFinishReason *string `json:"native_finish_reason,omitempty"`
33 | TokensPrompt *int `json:"tokens_prompt,omitempty"`
34 | TokensCompletion *int `json:"tokens_completion,omitempty"`
35 | NativeTokensPrompt *int `json:"native_tokens_prompt,omitempty"`
36 | NativeTokensCompletion *int `json:"native_tokens_completion,omitempty"`
37 | NativeTokensReasoning *int `json:"native_tokens_reasoning,omitempty"`
38 | NumMediaPrompt *int `json:"num_media_prompt,omitempty"`
39 | NumMediaCompletion *int `json:"num_media_completion,omitempty"`
40 | NumSearchResults *int `json:"num_search_results,omitempty"`
41 | }
42 |
43 | func (c *Client) GetGeneration(ctx context.Context, id string) (generation Generation, err error) {
44 | query := url.Values{}
45 |
46 | query.Set("id", id)
47 |
48 | req, err := c.newRequest(
49 | ctx,
50 | http.MethodGet,
51 | c.fullURL(getGenerationSuffix, withQuery(query)),
52 | )
53 | if err != nil {
54 | return
55 | }
56 |
57 | var response struct {
58 | Data Generation `json:"data"`
59 | }
60 |
61 | err = c.sendRequest(req, &response)
62 |
63 | generation = response.Data
64 | return
65 | }
66 |
--------------------------------------------------------------------------------
/models.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | )
7 |
8 | const (
9 | listModelsSuffix = "/models"
10 | listUserModelsSuffix = "/models/user"
11 | listEmbeddingsModelsSuffix = "/embeddings/models"
12 | )
13 |
14 | type ModelArchitecture struct {
15 | InputModalities []string `json:"input_modalities"`
16 | OutputModalities []string `json:"output_modalities"`
17 | Tokenizer string `json:"tokenizer"`
18 | InstructType *string `json:"instruct_type,omitempty"`
19 | }
20 |
21 | type ModelTopProvider struct {
22 | IsModerated bool `json:"is_moderated"`
23 | ContextLength *int64 `json:"context_length,omitempty"`
24 | MaxCompletionTokens *int64 `json:"max_completion_tokens,omitempty"`
25 | }
26 |
27 | type ModelPricing struct {
28 | Prompt string `json:"prompt"`
29 | Completion string `json:"completion"`
30 | Image string `json:"image"`
31 | Request string `json:"request"`
32 | WebSearch string `json:"web_search"`
33 | InternalReasoning string `json:"internal_reasoning"`
34 | InputCacheRead *string `json:"input_cache_read,omitempty"`
35 | InputCacheWrite *string `json:"input_cache_write,omitempty"`
36 | }
37 |
38 | type Model struct {
39 | ID string `json:"id"`
40 | Name string `json:"name"`
41 | Created int64 `json:"created"`
42 | Description string `json:"description"`
43 | Architecture ModelArchitecture `json:"architecture"`
44 | TopProvider ModelTopProvider `json:"top_provider"`
45 | Pricing ModelPricing `json:"pricing"`
46 | CanonicalSlug *string `json:"canonical_slug,omitempty"`
47 | ContextLength *int64 `json:"context_length,omitempty"`
48 | HuggingFaceID *string `json:"hugging_face_id,omitempty"`
49 | PerRequestLimits any `json:"per_request_limits,omitempty"`
50 | SupportedParameters []string `json:"supported_parameters,omitempty"`
51 | }
52 |
53 | func (c *Client) ListModels(ctx context.Context) (models []Model, err error) {
54 | req, err := c.newRequest(
55 | ctx,
56 | http.MethodGet,
57 | c.fullURL(listModelsSuffix),
58 | )
59 | if err != nil {
60 | return
61 | }
62 |
63 | var response struct {
64 | Data []Model `json:"data"`
65 | }
66 |
67 | err = c.sendRequest(req, &response)
68 |
69 | models = response.Data
70 | return
71 | }
72 |
73 | func (c *Client) ListUserModels(ctx context.Context) (models []Model, err error) {
74 | req, err := c.newRequest(
75 | ctx,
76 | http.MethodGet,
77 | c.fullURL(listUserModelsSuffix),
78 | )
79 | if err != nil {
80 | return
81 | }
82 |
83 | var response struct {
84 | Data []Model `json:"data"`
85 | }
86 |
87 | err = c.sendRequest(req, &response)
88 |
89 | models = response.Data
90 | return
91 | }
92 |
93 | // ListEmbeddingsModels returns all available embeddings models and their properties.
94 | // API reference: https://openrouter.ai/docs/api/api-reference/embeddings/list-embeddings-models
95 | func (c *Client) ListEmbeddingsModels(ctx context.Context) ([]Model, error) {
96 | req, err := c.newRequest(
97 | ctx,
98 | http.MethodGet,
99 | c.fullURL(listEmbeddingsModelsSuffix),
100 | )
101 | if err != nil {
102 | return nil, err
103 | }
104 |
105 | var response struct {
106 | Data []Model `json:"data"`
107 | }
108 |
109 | if err := c.sendRequest(req, &response); err != nil {
110 | return nil, err
111 | }
112 |
113 | return response.Data, nil
114 | }
115 |
--------------------------------------------------------------------------------
/error.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "strings"
7 | )
8 |
9 | // APIError provides error information returned by the Openrouter API.
10 | type APIError struct {
11 | Code any `json:"code,omitempty"`
12 | Message string `json:"message"`
13 | Metadata *Metadata `json:"metadata,omitempty"`
14 |
15 | // Internal fields
16 | HTTPStatusCode int `json:"-"`
17 | ProviderError *ProviderError `json:"-"`
18 | }
19 |
20 | // Metadata provides additional information about the error.
21 | type Metadata map[string]any
22 |
23 | // ProviderError provides the provider error (if available).
24 | type ProviderError map[string]any
25 |
26 | // RequestError provides information about generic request errors.
27 | type RequestError struct {
28 | HTTPStatus string
29 | HTTPStatusCode int
30 | Err error
31 | Body []byte
32 | }
33 |
34 | type ErrorResponse struct {
35 | Error *APIError `json:"error,omitempty"`
36 | }
37 |
38 | func (e *ProviderError) Message() any {
39 | // {"message": "string"}
40 | messageAny, ok := (*e)["message"]
41 | if ok {
42 | return messageAny
43 | }
44 |
45 | // {"error": {"message": "string"}}
46 | errAny, ok := (*e)["error"]
47 | if !ok {
48 | return nil
49 | }
50 |
51 | err, ok := errAny.(map[string]any)
52 | if !ok {
53 | return errAny
54 | }
55 |
56 | messageAny, ok = err["message"]
57 | if ok {
58 | return messageAny
59 | }
60 |
61 | return err
62 | }
63 |
64 | func (e *APIError) Error() string {
65 | // If it has a provider error
66 | if e.ProviderError != nil {
67 | if message := e.ProviderError.Message(); message != nil {
68 | return fmt.Sprintf("provider error, code: %v, message: %v", e.Code, message)
69 | }
70 |
71 | return fmt.Sprintf("provider error, code: %v, message: %s, error: %v", e.Code, e.Message, *e.ProviderError)
72 | }
73 |
74 | // If it has metadata
75 | if e.Metadata != nil {
76 | return fmt.Sprintf("error, code: %v, message: %s, metadata: %v", e.Code, e.Message, *e.Metadata)
77 | }
78 |
79 | return e.Message
80 | }
81 |
82 | func (e *APIError) UnmarshalJSON(data []byte) (err error) {
83 | var rawMap map[string]json.RawMessage
84 | err = json.Unmarshal(data, &rawMap)
85 | if err != nil {
86 | return
87 | }
88 |
89 | err = json.Unmarshal(rawMap["message"], &e.Message)
90 | if err != nil {
91 | var messages []string
92 | err = json.Unmarshal(rawMap["message"], &messages)
93 | if err != nil {
94 | return
95 | }
96 | e.Message = strings.Join(messages, ", ")
97 | }
98 |
99 | if meta, ok := rawMap["metadata"]; ok {
100 | err = json.Unmarshal(meta, &e.Metadata)
101 | if err != nil {
102 | return
103 | }
104 | }
105 |
106 | if e.Metadata != nil {
107 | raw, ok := (*e.Metadata)["raw"].(string)
108 | if ok {
109 | err = json.Unmarshal([]byte(raw), &e.ProviderError)
110 | if err != nil {
111 | return
112 | }
113 | }
114 | }
115 |
116 | if _, ok := rawMap["code"]; !ok {
117 | return nil
118 | }
119 |
120 | // if the api returned a number, we need to force an integer
121 | // since the json package defaults to float64
122 | var intCode int
123 | err = json.Unmarshal(rawMap["code"], &intCode)
124 | if err == nil {
125 | e.Code = intCode
126 | return nil
127 | }
128 |
129 | return json.Unmarshal(rawMap["code"], &e.Code)
130 | }
131 |
132 | func (e *RequestError) Error() string {
133 | return fmt.Sprintf(
134 | "error, status code: %d, status: %s, message: %s, body: %s",
135 | e.HTTPStatusCode, e.HTTPStatus, e.Err, e.Body,
136 | )
137 | }
138 |
139 | func (e *RequestError) Unwrap() error {
140 | return e.Err
141 | }
142 |
--------------------------------------------------------------------------------
/examples/completion-tool/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "os"
8 |
9 | "github.com/revrost/go-openrouter"
10 | "github.com/revrost/go-openrouter/jsonschema"
11 | )
12 |
13 | func main() {
14 | ctx := context.Background()
15 | client := openrouter.NewClient(os.Getenv("OPENROUTER_API_KEY"))
16 | var provider *openrouter.ChatProvider
17 |
18 | // describe the function & its inputs
19 | params := jsonschema.Definition{
20 | Type: jsonschema.Object,
21 | Properties: map[string]jsonschema.Definition{
22 | "location": {
23 | Type: jsonschema.String,
24 | Description: "The city and state, e.g. San Francisco, CA",
25 | },
26 | "unit": {
27 | Type: jsonschema.String,
28 | Enum: []string{"celsius", "fahrenheit"},
29 | },
30 | },
31 | Required: []string{"location"},
32 | }
33 | f := openrouter.FunctionDefinition{
34 | Name: "get_current_weather",
35 | Description: "Get the current weather in a given location",
36 | Parameters: params,
37 | }
38 | t := openrouter.Tool{
39 | Type: openrouter.ToolTypeFunction,
40 | Function: &f,
41 | }
42 |
43 | // simulate user asking a question that requires the function
44 | dialogue := []openrouter.ChatCompletionMessage{
45 | {Role: openrouter.ChatMessageRoleUser, Content: openrouter.Content{Text: "What is the weather in Boston today?"}},
46 | }
47 | fmt.Printf("Asking openrouter '%v' and providing it a '%v()' function...\n",
48 | dialogue[0].Content, f.Name)
49 |
50 | resp, err := client.CreateChatCompletion(ctx,
51 | openrouter.ChatCompletionRequest{
52 | Model: openrouter.GeminiFlash8B,
53 | Provider: provider,
54 | Messages: dialogue,
55 | Tools: []openrouter.Tool{t},
56 | },
57 | )
58 | if err != nil || len(resp.Choices) != 1 {
59 | fmt.Printf("Completion error: err:%v len(choices):%v\n", err,
60 | len(resp.Choices))
61 | b, _ := json.MarshalIndent(resp, "", "\t")
62 | fmt.Printf("resp :\n %s\n", string(b))
63 | return
64 | }
65 |
66 | type Argument struct {
67 | Location string `json:"location"`
68 | Unit string `json:"unit"`
69 | }
70 | b, _ := json.MarshalIndent(resp, "", "\t")
71 | fmt.Printf("resp :\n %s\n", string(b))
72 | msg := resp.Choices[0].Message
73 | for len(msg.ToolCalls) > 0 {
74 | dialogue = append(dialogue, msg)
75 | fmt.Printf("openrouter called us back wanting to invoke our function '%v' with params '%v'\n",
76 | msg.ToolCalls[0].Function.Name, msg.ToolCalls[0].Function.Arguments)
77 |
78 | args := Argument{}
79 | if err := json.Unmarshal([]byte(msg.ToolCalls[0].Function.Arguments), &args); err != nil {
80 | fmt.Printf("Error unmarshalling arguments: %v\n", err)
81 | return
82 | }
83 | content := ""
84 | if args.Unit == "celsius" {
85 | content = "Sunny and 26 degrees."
86 | } else {
87 | content = "Sunny and 80 degrees."
88 | }
89 | dialogue = append(dialogue, openrouter.ChatCompletionMessage{
90 | Role: openrouter.ChatMessageRoleTool,
91 | Content: openrouter.Content{Text: content},
92 | ToolCallID: msg.ToolCalls[0].ID,
93 | })
94 |
95 | // simulate calling the function & responding to openrouter
96 | fmt.Println("Sending openrouter function's response and requesting the reply to the original question...")
97 | resp, err = client.CreateChatCompletion(ctx,
98 | openrouter.ChatCompletionRequest{
99 | Model: openrouter.GeminiFlash8B,
100 | Provider: provider,
101 | Messages: dialogue,
102 | Tools: []openrouter.Tool{t},
103 | },
104 | )
105 | if err != nil || len(resp.Choices) != 1 {
106 | fmt.Printf("Tool completion error: err:%v len(choices):%v\n", err,
107 | len(resp.Choices))
108 | return
109 | }
110 |
111 | msg = resp.Choices[0].Message
112 | }
113 | fmt.Printf("openrouter answered the original request with: %v\n", msg.Content)
114 | }
115 |
--------------------------------------------------------------------------------
/embeddings.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | )
9 |
10 | const embeddingsSuffix = "/embeddings"
11 |
12 | // EmbeddingsEncodingFormat controls how embeddings are returned by the API.
13 | // See: https://openrouter.ai/docs/api/api-reference/embeddings/create-embeddings
14 | type EmbeddingsEncodingFormat string
15 |
16 | const (
17 | EmbeddingsEncodingFormatFloat EmbeddingsEncodingFormat = "float"
18 | EmbeddingsEncodingFormatBase64 EmbeddingsEncodingFormat = "base64"
19 | )
20 |
21 | // EmbeddingsRequest represents a request to the /embeddings endpoint.
22 | //
23 | // The input field is intentionally typed as any to support the flexible input
24 | // types accepted by the OpenRouter API:
25 | // - string
26 | // - []string
27 | // - []float64
28 | // - [][]float64
29 | // - structured content blocks
30 | //
31 | // For examples, see: https://openrouter.ai/docs/api/api-reference/embeddings/create-embeddings
32 | type EmbeddingsRequest struct {
33 | // Model is the model slug to use for embeddings.
34 | Model string `json:"model"`
35 | // Input is the content to embed. See the API docs for supported formats.
36 | Input any `json:"input"`
37 |
38 | // EncodingFormat controls how the embedding is returned: "float" or "base64".
39 | EncodingFormat EmbeddingsEncodingFormat `json:"encoding_format,omitempty"`
40 | // Dimensions optionally truncates the embedding to the given number of dimensions.
41 | Dimensions *int `json:"dimensions,omitempty"`
42 | // User is an optional identifier for the end-user making the request.
43 | User string `json:"user,omitempty"`
44 | // Provider configuration for provider routing. This reuses the same structure
45 | // as chat/completions provider routing, which is compatible with the embeddings API.
46 | Provider *ChatProvider `json:"provider,omitempty"`
47 | // InputType is an optional hint describing the type of input, e.g. "text" or "image".
48 | InputType string `json:"input_type,omitempty"`
49 | }
50 |
51 | // EmbeddingValue represents a single embedding, which can be returned either as
52 | // a vector of floats or as a base64 string depending on encoding_format.
53 | type EmbeddingValue struct {
54 | Vector []float64
55 | Base64 string
56 | }
57 |
58 | func (e *EmbeddingValue) UnmarshalJSON(data []byte) error {
59 | // Try to unmarshal as []float64 first (encoding_format: "float").
60 | var vec []float64
61 | if err := json.Unmarshal(data, &vec); err == nil {
62 | e.Vector = vec
63 | e.Base64 = ""
64 | return nil
65 | }
66 |
67 | // Fallback to string (encoding_format: "base64").
68 | var s string
69 | if err := json.Unmarshal(data, &s); err == nil {
70 | e.Base64 = s
71 | e.Vector = nil
72 | return nil
73 | }
74 |
75 | return fmt.Errorf("embedding: invalid format, expected []float64 or string")
76 | }
77 |
78 | // EmbeddingData represents a single embedding entry in the response.
79 | type EmbeddingData struct {
80 | Object string `json:"object"`
81 | Embedding EmbeddingValue `json:"embedding"`
82 | Index int `json:"index"`
83 | }
84 |
85 | // EmbeddingsUsage represents the token and cost statistics for an embeddings request.
86 | type EmbeddingsUsage struct {
87 | PromptTokens int `json:"prompt_tokens"`
88 | TotalTokens int `json:"total_tokens"`
89 | Cost float64 `json:"cost"`
90 | }
91 |
92 | // EmbeddingsResponse represents the response from the /embeddings endpoint.
93 | type EmbeddingsResponse struct {
94 | ID string `json:"id"`
95 | Object string `json:"object"`
96 | Data []EmbeddingData `json:"data"`
97 | Model string `json:"model"`
98 | Usage *EmbeddingsUsage `json:"usage,omitempty"`
99 | }
100 |
101 | // CreateEmbeddings submits an embedding request to the embeddings router.
102 | //
103 | // API reference: https://openrouter.ai/docs/api/api-reference/embeddings/create-embeddings
104 | func (c *Client) CreateEmbeddings(
105 | ctx context.Context,
106 | request EmbeddingsRequest,
107 | ) (EmbeddingsResponse, error) {
108 | req, err := c.newRequest(
109 | ctx,
110 | http.MethodPost,
111 | c.fullURL(embeddingsSuffix),
112 | withBody(request),
113 | )
114 | if err != nil {
115 | return EmbeddingsResponse{}, err
116 | }
117 |
118 | var response EmbeddingsResponse
119 | if err := c.sendRequest(req, &response); err != nil {
120 | return EmbeddingsResponse{}, err
121 | }
122 |
123 | return response, nil
124 | }
125 |
--------------------------------------------------------------------------------
/client.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "net/url"
10 | )
11 |
12 | type Client struct {
13 | config ClientConfig
14 |
15 | requestBuilder RequestBuilder
16 | }
17 |
18 | func NewClient(auth string, opts ...Option) *Client {
19 | config := DefaultConfig(auth)
20 |
21 | for _, opt := range opts {
22 | opt(config)
23 | }
24 |
25 | return NewClientWithConfig(*config)
26 | }
27 |
28 | func NewClientWithConfig(config ClientConfig) *Client {
29 | return &Client{
30 | config: config,
31 | requestBuilder: NewRequestBuilder(),
32 | }
33 | }
34 |
35 | func (c *Client) sendRequest(req *http.Request, v any) error {
36 | req.Header.Set("Accept", "application/json; charset=utf-8")
37 |
38 | // Check whether Content-Type is already set, Upload Files API requires
39 | // Content-Type == multipart/form-data
40 | contentType := req.Header.Get("Content-Type")
41 | if contentType == "" {
42 | req.Header.Set("Content-Type", "application/json; charset=utf-8")
43 | }
44 |
45 | c.setCommonHeaders(req)
46 |
47 | res, err := c.config.HTTPClient.Do(req)
48 | if err != nil {
49 | return err
50 | }
51 | defer res.Body.Close()
52 |
53 | if isFailureStatusCode(res) {
54 | return c.handleErrorResp(res)
55 | }
56 |
57 | return decodeResponse(res.Body, v)
58 | }
59 |
60 | func (c *Client) setCommonHeaders(req *http.Request) {
61 | req.Header.Set("HTTP-Referer", c.config.HttpReferer)
62 | req.Header.Set("X-Title", c.config.XTitle)
63 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.config.authToken))
64 | }
65 |
66 | func isFailureStatusCode(resp *http.Response) bool {
67 | return resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest
68 | }
69 |
70 | func decodeResponse(body io.Reader, v any) error {
71 | if v == nil {
72 | return nil
73 | }
74 |
75 | if result, ok := v.(*string); ok {
76 | return decodeString(body, result)
77 | }
78 | return json.NewDecoder(body).Decode(v)
79 | }
80 |
81 | func decodeString(body io.Reader, output *string) error {
82 | b, err := io.ReadAll(body)
83 | if err != nil {
84 | return err
85 | }
86 | *output = string(b)
87 | return nil
88 | }
89 |
90 | type fullUrlOptions struct {
91 | query url.Values
92 | }
93 |
94 | type fullUrlOption func(*fullUrlOptions)
95 |
96 | func withQuery(query url.Values) fullUrlOption {
97 | return func(args *fullUrlOptions) {
98 | args.query = query
99 | }
100 | }
101 |
102 | // fullURL returns full URL for request.
103 | func (c *Client) fullURL(suffix string, setters ...fullUrlOption) string {
104 | // Default Options
105 | args := &fullUrlOptions{
106 | query: nil,
107 | }
108 | for _, setter := range setters {
109 | setter(args)
110 | }
111 |
112 | if args.query != nil {
113 | suffix = fmt.Sprintf("%s?%s", suffix, args.query.Encode())
114 | }
115 |
116 | return fmt.Sprintf("%s%s", c.config.BaseURL, suffix)
117 | }
118 |
119 | type requestOptions struct {
120 | body any
121 | header http.Header
122 | }
123 |
124 | type requestOption func(*requestOptions)
125 |
126 | func withBody(body any) requestOption {
127 | return func(args *requestOptions) {
128 | args.body = body
129 | }
130 | }
131 |
132 | func withContentType(contentType string) requestOption {
133 | return func(args *requestOptions) {
134 | args.header.Set("Content-Type", contentType)
135 | }
136 | }
137 |
138 | func (c *Client) newRequest(ctx context.Context, method, url string, setters ...requestOption) (*http.Request, error) {
139 | // Default Options
140 | args := &requestOptions{
141 | body: nil,
142 | header: make(http.Header),
143 | }
144 | for _, setter := range setters {
145 | setter(args)
146 | }
147 | req, err := c.requestBuilder.Build(ctx, method, url, args.body, args.header)
148 | if err != nil {
149 | return nil, err
150 | }
151 | c.setCommonHeaders(req)
152 | return req, nil
153 | }
154 |
155 | func (c *Client) newStreamRequest(
156 | ctx context.Context,
157 | method string,
158 | urlSuffix string,
159 | body any) (*http.Request, error) {
160 | req, err := c.requestBuilder.Build(ctx, method, c.fullURL(urlSuffix), body, http.Header{
161 | "Content-Type": []string{"application/json"},
162 | "Accept": []string{"text/event-stream"},
163 | "Cache-Control": []string{"no-cache"},
164 | "Connection": []string{"keep-alive"},
165 | })
166 | if err != nil {
167 | return nil, err
168 | }
169 |
170 | c.setCommonHeaders(req)
171 | return req, nil
172 | }
173 |
174 | func (c *Client) handleErrorResp(resp *http.Response) error {
175 | var errRes ErrorResponse
176 |
177 | err := json.NewDecoder(resp.Body).Decode(&errRes)
178 | if err != nil || errRes.Error == nil {
179 | reqErr := &RequestError{
180 | HTTPStatusCode: resp.StatusCode,
181 | Err: err,
182 | }
183 | if errRes.Error != nil {
184 | reqErr.Err = errRes.Error
185 | }
186 | return reqErr
187 | }
188 |
189 | errRes.Error.HTTPStatusCode = resp.StatusCode
190 | return errRes.Error
191 | }
192 |
--------------------------------------------------------------------------------
/chat_test.go:
--------------------------------------------------------------------------------
1 | package openrouter_test
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 |
7 | openrouter "github.com/revrost/go-openrouter"
8 | )
9 |
10 | // ChatCompletionMessage json.Marshal tests
11 |
12 | // Tests the case where MultiContent is not empty
13 | func TestChatCompletionMessageMarshalJSON_MultiContent(t *testing.T) {
14 | parts := []openrouter.ChatMessagePart{
15 | {
16 | Type: openrouter.ChatMessagePartTypeText,
17 | Text: "What is in this image?",
18 | },
19 | {
20 | Type: openrouter.ChatMessagePartTypeImageURL,
21 | ImageURL: &openrouter.ChatMessageImageURL{
22 | URL: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg",
23 | },
24 | },
25 | }
26 | message := openrouter.ChatCompletionMessage{
27 | Role: openrouter.ChatMessageRoleUser,
28 | Content: openrouter.Content{Multi: parts},
29 | }
30 |
31 | expected := `{"role":"user","content":[{"type":"text","text":"What is in this image?"},{"type":"image_url","image_url":{"url":"https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"}}]}`
32 | marshalAndValidate(t, message, expected)
33 | }
34 |
35 | // Tests the case where Content is used (MultiContent is empty)
36 | func TestChatCompletionMessageMarshalJSON_Content(t *testing.T) {
37 | message := openrouter.ChatCompletionMessage{
38 | Role: openrouter.ChatMessageRoleUser,
39 | Content: openrouter.Content{Text: "This is a simple content"},
40 | }
41 |
42 | expected := `{"role":"user","content":"This is a simple content"}`
43 | marshalAndValidate(t, message, expected)
44 | }
45 |
46 | func marshalAndValidate(t *testing.T, message openrouter.ChatCompletionMessage, expected string) {
47 | // Calls MarshalJSON
48 | result, err := json.Marshal(message)
49 | if err != nil {
50 | t.Fatalf("expected no error, got %v", err)
51 | }
52 |
53 | // Validates the resulting JSON
54 | if string(result) != expected {
55 | t.Errorf("expected %s, got %s", expected, result)
56 | }
57 | }
58 |
59 | func TestUnmarshalChatCompletionMessage(t *testing.T) {
60 | input := `{"role":"user","content":"This is a simple content"}`
61 | var message openrouter.ChatCompletionMessage
62 | err := json.Unmarshal([]byte(input), &message)
63 | if err != nil {
64 | t.Fatalf("expected no error, got %v", err)
65 | }
66 |
67 | if message.Role != openrouter.ChatMessageRoleUser {
68 | t.Errorf("expected %s, got %s", openrouter.ChatMessageRoleUser, message.Role)
69 | }
70 | if message.Content.Text != "This is a simple content" {
71 | t.Errorf("expected %s, got %s", "This is a simple content", message.Content.Text)
72 | }
73 | }
74 |
75 | func TestChatCompletionMessageMarshalJSON_MultiContent_WithPDF(t *testing.T) {
76 | parts := []openrouter.ChatMessagePart{
77 | {
78 | Type: openrouter.ChatMessagePartTypeText,
79 | Text: "Analyze this PDF document",
80 | },
81 | {
82 | Type: openrouter.ChatMessagePartTypeFile,
83 | File: &openrouter.FileContent{
84 | Filename: "document.pdf",
85 | FileData: "JVBERi0xLjQKJdPr6eEKMSAwIG9iago8PAovVGl0bGUgKFRlc3QgUERGKQo+PgplbmRvYmoKCjIgMCBvYmoKPDwKL1R5cGUgL0NhdGFsb2cKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0NvdW50IDEKL0tpZHMgWzQgMCBSXQo+PgplbmRvYmoKCjQgMCBvYmoKPDwKL1R5cGUgL1BhZ2UKL1BhcmVudCAzIDAgUgovTWVkaWFCb3ggWzAgMCA2MTIgNzkyXQovUmVzb3VyY2VzIDw8Cj4+Ci9Db250ZW50cyA1IDAgUgo+PgplbmRvYmoKCjUgMCBvYmoKPDwKL0xlbmd0aCA0NAo+PgpzdHJlYW0KQlQKL0YxIDEyIFRmCjEwMCA3MDAgVGQKKEhlbGxvIFdvcmxkKSBUagoKRVQKZW5kc3RyZWFtCmVuZG9iagoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDE2IDAwMDAwIG4gCjAwMDAwMDA2NSAwMDAwMCBuIAowMDAwMDAwMTEyIDAwMDAwIG4gCjAwMDAwMDAxNjkgMDAwMDAgbiAKMDAwMDAwMDMwMCAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDYKL1Jvb3QgMiAwIFIKL0luZm8gMSAwIFIKPj4Kc3RhcnR4cmVmCjM5MwolJUVPRgo=",
86 | },
87 | },
88 | }
89 | message := openrouter.ChatCompletionMessage{
90 | Role: openrouter.ChatMessageRoleUser,
91 | Content: openrouter.Content{Multi: parts},
92 | }
93 |
94 | expected := `{"role":"user","content":[{"type":"text","text":"Analyze this PDF document"},{"type":"file","file":{"filename":"document.pdf","file_data":"JVBERi0xLjQKJdPr6eEKMSAwIG9iago8PAovVGl0bGUgKFRlc3QgUERGKQo+PgplbmRvYmoKCjIgMCBvYmoKPDwKL1R5cGUgL0NhdGFsb2cKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0NvdW50IDEKL0tpZHMgWzQgMCBSXQo+PgplbmRvYmoKCjQgMCBvYmoKPDwKL1R5cGUgL1BhZ2UKL1BhcmVudCAzIDAgUgovTWVkaWFCb3ggWzAgMCA2MTIgNzkyXQovUmVzb3VyY2VzIDw8Cj4+Ci9Db250ZW50cyA1IDAgUgo+PgplbmRvYmoKCjUgMCBvYmoKPDwKL0xlbmd0aCA0NAo+PgpzdHJlYW0KQlQKL0YxIDEyIFRmCjEwMCA3MDAgVGQKKEhlbGxvIFdvcmxkKSBUagoKRVQKZW5kc3RyZWFtCmVuZG9iagoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDE2IDAwMDAwIG4gCjAwMDAwMDA2NSAwMDAwMCBuIAowMDAwMDAwMTEyIDAwMDAwIG4gCjAwMDAwMDAxNjkgMDAwMDAgbiAKMDAwMDAwMDMwMCAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDYKL1Jvb3QgMiAwIFIKL0luZm8gMSAwIFIKPj4Kc3RhcnR4cmVmCjM5MwolJUVPRgo="}}]}`
95 | marshalAndValidate(t, message, expected)
96 | }
97 |
98 | func TestChatCompletionMessagePromptCachingApplies(t *testing.T) {
99 | message := openrouter.ChatCompletionMessage{
100 | Role: openrouter.ChatMessageRoleUser,
101 | Content: openrouter.Content{Multi: []openrouter.ChatMessagePart{
102 | {Text: "This is a simple content", CacheControl: &openrouter.CacheControl{
103 | Type: "ephemeral",
104 | }},
105 | },
106 | }}
107 |
108 | expected := `{"role":"user","content":[{"text":"This is a simple content","cache_control":{"type":"ephemeral"}}]}`
109 | marshalAndValidate(t, message, expected)
110 | }
111 |
--------------------------------------------------------------------------------
/jsonschema/json.go:
--------------------------------------------------------------------------------
1 | // Package jsonschema provides very simple functionality for representing a JSON schema as a
2 | // (nested) struct. This struct can be used with the chat completion "function call" feature.
3 | // For more complicated schemas, it is recommended to use a dedicated JSON schema library
4 | // and/or pass in the schema in []byte format.
5 | package jsonschema
6 |
7 | import (
8 | "encoding/json"
9 | "fmt"
10 | "reflect"
11 | "strconv"
12 | "strings"
13 | )
14 |
15 | type DataType string
16 |
17 | const (
18 | Object DataType = "object"
19 | Number DataType = "number"
20 | Integer DataType = "integer"
21 | String DataType = "string"
22 | Array DataType = "array"
23 | Null DataType = "null"
24 | Boolean DataType = "boolean"
25 | )
26 |
27 | // Definition is a struct for describing a JSON Schema.
28 | // It is fairly limited, and you may have better luck using a third-party library.
29 | type Definition struct {
30 | // Type specifies the data type of the schema.
31 | Type DataType `json:"type,omitempty"`
32 | // Description is the description of the schema.
33 | Description string `json:"description,omitempty"`
34 | // Enum is used to restrict a value to a fixed set of values. It must be an array with at least
35 | // one element, where each element is unique. You will probably only use this with strings.
36 | Enum []string `json:"enum,omitempty"`
37 | // Properties describes the properties of an object, if the schema type is Object.
38 | Properties map[string]Definition `json:"properties,omitempty"`
39 | // Required specifies which properties are required, if the schema type is Object.
40 | Required []string `json:"required,omitempty"`
41 | // Items specifies which data type an array contains, if the schema type is Array.
42 | Items *Definition `json:"items,omitempty"`
43 | // AdditionalProperties is used to control the handling of properties in an object
44 | // that are not explicitly defined in the properties section of the schema. example:
45 | // additionalProperties: true
46 | // additionalProperties: false
47 | // additionalProperties: jsonschema.Definition{Type: jsonschema.String}
48 | AdditionalProperties any `json:"additionalProperties,omitempty"`
49 | // Whether the schema is nullable or not.
50 | Nullable bool `json:"nullable,omitempty"`
51 | }
52 |
53 | func (d *Definition) MarshalJSON() ([]byte, error) {
54 | if d.Properties == nil {
55 | d.Properties = make(map[string]Definition)
56 | }
57 | type Alias Definition
58 | return json.Marshal(struct {
59 | Alias
60 | }{
61 | Alias: (Alias)(*d),
62 | })
63 | }
64 |
65 | func (d *Definition) Unmarshal(content string, v any) error {
66 | return VerifySchemaAndUnmarshal(*d, []byte(content), v)
67 | }
68 |
69 | func GenerateSchema[T any]() (*Definition, error) {
70 | var v T
71 | return reflectSchema(reflect.TypeOf(v))
72 | }
73 |
74 | func GenerateSchemaForType(v any) (*Definition, error) {
75 | return reflectSchema(reflect.TypeOf(v))
76 | }
77 |
78 | func reflectSchema(t reflect.Type) (*Definition, error) {
79 | var d Definition
80 | switch t.Kind() {
81 | case reflect.String:
82 | d.Type = String
83 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
84 | reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
85 | d.Type = Integer
86 | case reflect.Float32, reflect.Float64:
87 | d.Type = Number
88 | case reflect.Bool:
89 | d.Type = Boolean
90 | case reflect.Slice, reflect.Array:
91 | d.Type = Array
92 | items, err := reflectSchema(t.Elem())
93 | if err != nil {
94 | return nil, err
95 | }
96 | d.Items = items
97 | case reflect.Struct:
98 | d.Type = Object
99 | d.AdditionalProperties = false
100 | object, err := reflectSchemaObject(t)
101 | if err != nil {
102 | return nil, err
103 | }
104 | d = *object
105 | case reflect.Ptr:
106 | definition, err := reflectSchema(t.Elem())
107 | if err != nil {
108 | return nil, err
109 | }
110 | d = *definition
111 | case reflect.Invalid, reflect.Uintptr, reflect.Complex64, reflect.Complex128,
112 | reflect.Chan, reflect.Func, reflect.Interface, reflect.Map,
113 | reflect.UnsafePointer:
114 | return nil, fmt.Errorf("unsupported type: %s", t.Kind().String())
115 | default:
116 | }
117 | return &d, nil
118 | }
119 |
120 | func reflectSchemaObject(t reflect.Type) (*Definition, error) {
121 | var d = Definition{
122 | Type: Object,
123 | AdditionalProperties: false,
124 | }
125 | properties := make(map[string]Definition)
126 | var requiredFields []string
127 | for i := range t.NumField() {
128 | field := t.Field(i)
129 | if !field.IsExported() {
130 | continue
131 | }
132 | skipSchema := field.Tag.Get("skipschema")
133 | if skipSchema == "true" {
134 | continue
135 | }
136 | jsonTag := field.Tag.Get("json")
137 | var required = true
138 | switch {
139 | case jsonTag == "-":
140 | continue
141 | case jsonTag == "":
142 | jsonTag = field.Name
143 | case strings.HasSuffix(jsonTag, ",omitempty"):
144 | jsonTag = strings.TrimSuffix(jsonTag, ",omitempty")
145 | required = false
146 | }
147 |
148 | item, err := reflectSchema(field.Type)
149 | if err != nil {
150 | return nil, err
151 | }
152 | description := field.Tag.Get("description")
153 | if description != "" {
154 | item.Description = description
155 | }
156 | enum := field.Tag.Get("enum")
157 | if enum != "" {
158 | item.Enum = strings.Split(enum, ",")
159 | }
160 |
161 | if n := field.Tag.Get("nullable"); n != "" {
162 | nullable, _ := strconv.ParseBool(n)
163 | item.Nullable = nullable
164 | }
165 |
166 | properties[jsonTag] = *item
167 |
168 | if s := field.Tag.Get("required"); s != "" {
169 | required, _ = strconv.ParseBool(s)
170 | }
171 | if required {
172 | requiredFields = append(requiredFields, jsonTag)
173 | }
174 | }
175 | d.Required = requiredFields
176 | d.Properties = properties
177 | return &d, nil
178 | }
179 |
--------------------------------------------------------------------------------
/jsonschema/validate_test.go:
--------------------------------------------------------------------------------
1 | package jsonschema_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/revrost/go-openrouter/jsonschema"
7 | )
8 |
9 | func Test_Validate(t *testing.T) {
10 | type args struct {
11 | data any
12 | schema jsonschema.Definition
13 | }
14 | tests := []struct {
15 | name string
16 | args args
17 | want bool
18 | }{
19 | // string integer number boolean
20 | {"", args{data: "ABC", schema: jsonschema.Definition{Type: jsonschema.String}}, true},
21 | {"", args{data: 123, schema: jsonschema.Definition{Type: jsonschema.String}}, false},
22 | {"", args{data: 123, schema: jsonschema.Definition{Type: jsonschema.Integer}}, true},
23 | {"", args{data: 123.4, schema: jsonschema.Definition{Type: jsonschema.Integer}}, false},
24 | {"", args{data: "ABC", schema: jsonschema.Definition{Type: jsonschema.Number}}, false},
25 | {"", args{data: 123, schema: jsonschema.Definition{Type: jsonschema.Number}}, true},
26 | {"", args{data: false, schema: jsonschema.Definition{Type: jsonschema.Boolean}}, true},
27 | {"", args{data: 123, schema: jsonschema.Definition{Type: jsonschema.Boolean}}, false},
28 | {"", args{data: nil, schema: jsonschema.Definition{Type: jsonschema.Null}}, true},
29 | {"", args{data: 0, schema: jsonschema.Definition{Type: jsonschema.Null}}, false},
30 | // array
31 | {"", args{data: []any{"a", "b", "c"}, schema: jsonschema.Definition{
32 | Type: jsonschema.Array, Items: &jsonschema.Definition{Type: jsonschema.String}},
33 | }, true},
34 | {"", args{data: []any{1, 2, 3}, schema: jsonschema.Definition{
35 | Type: jsonschema.Array, Items: &jsonschema.Definition{Type: jsonschema.String}},
36 | }, false},
37 | {"", args{data: []any{1, 2, 3}, schema: jsonschema.Definition{
38 | Type: jsonschema.Array, Items: &jsonschema.Definition{Type: jsonschema.Integer}},
39 | }, true},
40 | {"", args{data: []any{1, 2, 3.4}, schema: jsonschema.Definition{
41 | Type: jsonschema.Array, Items: &jsonschema.Definition{Type: jsonschema.Integer}},
42 | }, false},
43 | // object
44 | {"", args{data: map[string]any{
45 | "string": "abc",
46 | "integer": 123,
47 | "number": 123.4,
48 | "boolean": false,
49 | "array": []any{1, 2, 3},
50 | }, schema: jsonschema.Definition{Type: jsonschema.Object, Properties: map[string]jsonschema.Definition{
51 | "string": {Type: jsonschema.String},
52 | "integer": {Type: jsonschema.Integer},
53 | "number": {Type: jsonschema.Number},
54 | "boolean": {Type: jsonschema.Boolean},
55 | "array": {Type: jsonschema.Array, Items: &jsonschema.Definition{Type: jsonschema.Number}},
56 | },
57 | Required: []string{"string"},
58 | }}, true},
59 | {"", args{data: map[string]any{
60 | "integer": 123,
61 | "number": 123.4,
62 | "boolean": false,
63 | "array": []any{1, 2, 3},
64 | }, schema: jsonschema.Definition{Type: jsonschema.Object, Properties: map[string]jsonschema.Definition{
65 | "string": {Type: jsonschema.String},
66 | "integer": {Type: jsonschema.Integer},
67 | "number": {Type: jsonschema.Number},
68 | "boolean": {Type: jsonschema.Boolean},
69 | "array": {Type: jsonschema.Array, Items: &jsonschema.Definition{Type: jsonschema.Number}},
70 | },
71 | Required: []string{"string"},
72 | }}, false},
73 | }
74 | for _, tt := range tests {
75 | t.Run(tt.name, func(t *testing.T) {
76 | if got := jsonschema.Validate(tt.args.schema, tt.args.data); got != tt.want {
77 | t.Errorf("Validate() = %v, want %v", got, tt.want)
78 | }
79 | })
80 | }
81 | }
82 |
83 | func TestUnmarshal(t *testing.T) {
84 | type args struct {
85 | schema jsonschema.Definition
86 | content []byte
87 | v any
88 | }
89 | tests := []struct {
90 | name string
91 | args args
92 | wantErr bool
93 | }{
94 | {"", args{
95 | schema: jsonschema.Definition{
96 | Type: jsonschema.Object,
97 | Properties: map[string]jsonschema.Definition{
98 | "string": {Type: jsonschema.String},
99 | "number": {Type: jsonschema.Number},
100 | },
101 | },
102 | content: []byte(`{"string":"abc","number":123.4}`),
103 | v: &struct {
104 | String string `json:"string"`
105 | Number float64 `json:"number"`
106 | }{},
107 | }, false},
108 | {"", args{
109 | schema: jsonschema.Definition{
110 | Type: jsonschema.Object,
111 | Properties: map[string]jsonschema.Definition{
112 | "string": {Type: jsonschema.String},
113 | "number": {Type: jsonschema.Number},
114 | },
115 | Required: []string{"string", "number"},
116 | },
117 | content: []byte(`{"string":"abc"}`),
118 | v: struct {
119 | String string `json:"string"`
120 | Number float64 `json:"number"`
121 | }{},
122 | }, true},
123 | {"validate integer", args{
124 | schema: jsonschema.Definition{
125 | Type: jsonschema.Object,
126 | Properties: map[string]jsonschema.Definition{
127 | "string": {Type: jsonschema.String},
128 | "integer": {Type: jsonschema.Integer},
129 | },
130 | Required: []string{"string", "integer"},
131 | },
132 | content: []byte(`{"string":"abc","integer":123}`),
133 | v: &struct {
134 | String string `json:"string"`
135 | Integer int `json:"integer"`
136 | }{},
137 | }, false},
138 | {"validate integer passes but truncates", args{
139 | schema: jsonschema.Definition{
140 | Type: jsonschema.Object,
141 | Properties: map[string]jsonschema.Definition{
142 | "string": {Type: jsonschema.String},
143 | "integer": {Type: jsonschema.Integer},
144 | },
145 | Required: []string{"string", "integer"},
146 | },
147 | content: []byte(`{"string":"abc","integer":123.4}`),
148 | v: &struct {
149 | String string `json:"string"`
150 | Integer int `json:"integer"`
151 | }{},
152 | }, true},
153 | }
154 | for _, tt := range tests {
155 | t.Run(tt.name, func(t *testing.T) {
156 | err := jsonschema.VerifySchemaAndUnmarshal(tt.args.schema, tt.args.content, tt.args.v)
157 | if (err != nil) != tt.wantErr {
158 | t.Errorf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr)
159 | } else if err == nil {
160 | t.Logf("Unmarshal() v = %+v\n", tt.args.v)
161 | }
162 | })
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Go Openrouter
2 |
3 | [](https://pkg.go.dev/github.com/revrost/go-openrouter)
4 | [](https://goreportcard.com/report/github.com/revrost/go-openrouter)
5 | [](https://codecov.io/gh/revrost/go-openrouter)
6 |
7 | This library provides unofficial Go client for [Openrouter API](https://openrouter.ai/docs/quick-start)
8 |
9 | ## Installation
10 |
11 | ```
12 | go get github.com/revrost/go-openrouter
13 | ```
14 |
15 | ### Getting an Openrouter API Key:
16 |
17 | 1. Visit the openrouter website at [https://openrouter.ai/docs/quick-start](https://openrouter.ai/docs/quick-start).
18 | 2. If you don't have an account, click on "Sign Up" to create one. If you do, click "Log In".
19 | 3. Once logged in, navigate to your API key management page.
20 | 4. Click on "Create new secret key".
21 | 5. Enter a name for your new key, then click "Create secret key".
22 | 6. Your new API key will be displayed. Use this key to interact with the openrouter API.
23 |
24 | **Note:** Your API key is sensitive information. Do not share it with anyone.
25 |
26 | For deepseek models, sometimes its better to use openrouter integration feature and pass in your own API key into the control panel for better performance, as openrouter will use your API key to make requests to the underlying model which potentially avoids shared rate limits.
27 |
28 | ⚡BYOK (Bring your own keys) gets 1 million free requests per month!
29 | https://openrouter.ai/announcements/1-million-free-byok-requests-per-month
30 |
31 | ## Features
32 |
33 | https://openrouter.ai/docs/api-reference/overview
34 |
35 | - [x] Chat Completion
36 | - [x] Completion
37 | - [x] Streaming
38 | - [x] Embeddings
39 | - [x] Reasoning
40 | - [x] Tool calling
41 | - [x] Structured outputs
42 | - [x] Prompt caching
43 | - [x] Web search
44 | - [x] Multimodal [Images, PDFs, Audio]
45 | - [x] Usage fields
46 |
47 | ## Usage
48 |
49 | ### Chat completion
50 |
51 | ```go
52 | package main
53 |
54 | import (
55 | "context"
56 | "fmt"
57 | openrouter "github.com/revrost/go-openrouter"
58 | )
59 |
60 | func main() {
61 | client := openrouter.NewClient(
62 | "your token",
63 | openrouter.WithXTitle("My App"),
64 | openrouter.WithHTTPReferer("https://myapp.com"),
65 | )
66 | resp, err := client.CreateChatCompletion(
67 | context.Background(),
68 | openrouter.ChatCompletionRequest{
69 | Model: "deepseek/deepseek-chat-v3.1:free",
70 | Messages: []openrouter.ChatCompletionMessage{
71 | openrouter.UserMessage("Hello!"),
72 | },
73 | },
74 | )
75 |
76 | if err != nil {
77 | fmt.Printf("ChatCompletion error: %v\n", err)
78 | return
79 | }
80 |
81 | fmt.Println(resp.Choices[0].Message.Content)
82 | }
83 | ```
84 |
85 | ### Streaming chat completion
86 |
87 | ```go
88 | func main() {
89 | ctx := context.Background()
90 | client := openrouter.NewClient(os.Getenv("OPENROUTER_API_KEY"))
91 |
92 | stream, err := client.CreateChatCompletionStream(
93 | context.Background(), openrouter.ChatCompletionRequest{
94 | Model: "qwen/qwen3-235b-a22b-07-25:free",
95 | Messages: []openrouter.ChatCompletionMessage{
96 | openrouter.UserMessage("Hello, how are you?"),
97 | },
98 | Stream: true,
99 | },
100 | )
101 | require.NoError(t, err)
102 | defer stream.Close()
103 |
104 | for {
105 | response, err := stream.Recv()
106 | if err != nil && err != io.EOF {
107 | require.NoError(t, err)
108 | }
109 | if errors.Is(err, io.EOF) {
110 | fmt.Println("EOF, stream finished")
111 | return
112 | }
113 | json, err := json.MarshalIndent(response, "", " ")
114 | require.NoError(t, err)
115 | fmt.Println(string(json))
116 | }
117 | }
118 | ```
119 |
120 | ### Other examples:
121 |
122 |
123 | JSON Schema for function calling
124 |
125 | ```json
126 | {
127 | "name": "get_current_weather",
128 | "description": "Get the current weather in a given location",
129 | "parameters": {
130 | "type": "object",
131 | "properties": {
132 | "location": {
133 | "type": "string",
134 | "description": "The city and state, e.g. San Francisco, CA"
135 | },
136 | "unit": {
137 | "type": "string",
138 | "enum": ["celsius", "fahrenheit"]
139 | }
140 | },
141 | "required": ["location"]
142 | }
143 | }
144 | ```
145 |
146 | Using the `jsonschema` package, this schema could be created using structs as such:
147 |
148 | ```go
149 | FunctionDefinition{
150 | Name: "get_current_weather",
151 | Parameters: jsonschema.Definition{
152 | Type: jsonschema.Object,
153 | Properties: map[string]jsonschema.Definition{
154 | "location": {
155 | Type: jsonschema.String,
156 | Description: "The city and state, e.g. San Francisco, CA",
157 | },
158 | "unit": {
159 | Type: jsonschema.String,
160 | Enum: []string{"celsius", "fahrenheit"},
161 | },
162 | },
163 | Required: []string{"location"},
164 | },
165 | }
166 | ```
167 |
168 | The `Parameters` field of a `FunctionDefinition` can accept either of the above styles, or even a nested struct from another library (as long as it can be marshalled into JSON).
169 |
170 |
171 |
172 |
173 | Structured Outputs
174 |
175 | ```go
176 | func main() {
177 | ctx := context.Background()
178 | client := openrouter.NewClient(os.Getenv("OPENROUTER_API_KEY"))
179 |
180 | type Result struct {
181 | Location string `json:"location"`
182 | Temperature float64 `json:"temperature"`
183 | Condition string `json:"condition"`
184 | }
185 | var result Result
186 | schema, err := jsonschema.GenerateSchemaForType(result)
187 | if err != nil {
188 | log.Fatalf("GenerateSchemaForType error: %v", err)
189 | }
190 |
191 | request := openrouter.ChatCompletionRequest{
192 | Model: openrouter.DeepseekV3,
193 | Messages: []openrouter.ChatCompletionMessage{
194 | {
195 | Role: openrouter.ChatMessageRoleUser,
196 | Content: openrouter.Content{Text: "What's the weather like in London?"},
197 | },
198 | },
199 | ResponseFormat: &openrouter.ChatCompletionResponseFormat{
200 | Type: openrouter.ChatCompletionResponseFormatTypeJSONSchema,
201 | JSONSchema: &openrouter.ChatCompletionResponseFormatJSONSchema{
202 | Name: "weather",
203 | Schema: schema,
204 | Strict: true,
205 | },
206 | },
207 | }
208 |
209 | pj, _ := json.MarshalIndent(request, "", "\t")
210 | fmt.Printf("request :\n %s\n", string(pj))
211 |
212 | res, err := client.CreateChatCompletion(ctx, request)
213 | if err != nil {
214 | fmt.Println("error", err)
215 | } else {
216 | b, _ := json.MarshalIndent(res, "", "\t")
217 | fmt.Printf("response :\n %s", string(b))
218 | }
219 | }
220 | ```
221 |
222 |
223 | More examples in `examples/` folder.
224 |
225 | ## Frequently Asked Questions
226 |
227 | ## Contributing
228 |
229 | [Contributing Guidelines](https://github.com/revrost/go-openrouter/blob/master/CONTRIBUTING.md), we hope to see your contributions!
230 |
--------------------------------------------------------------------------------
/completion.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "context"
7 | "encoding/json"
8 | "errors"
9 | "io"
10 | "log/slog"
11 | "net/http"
12 | "strings"
13 | )
14 |
15 | const completionsSuffix = "/completions"
16 |
17 | var (
18 | ErrCompletionInvalidModel = errors.New("this model is not supported with this method, please use CreateChatCompletion client method instead") //nolint:lll
19 | ErrCompletionStreamNotSupported = errors.New("streaming is not supported with this method, please use CreateCompletion") //nolint:lll
20 | )
21 |
22 | type CompletionRequest struct {
23 | Model string `json:"model,omitempty"`
24 | // The prompt to complete
25 | Prompt string `json:"prompt"`
26 | // Optional model fallbacks: https://openrouter.ai/docs/features/model-routing#the-models-parameter
27 | Models []string `json:"models,omitempty"`
28 | Provider *ChatProvider `json:"provider,omitempty"`
29 | Reasoning *ChatCompletionReasoning `json:"reasoning,omitempty"`
30 | Usage *IncludeUsage `json:"usage,omitempty"`
31 | // Apply message transforms
32 | // https://openrouter.ai/docs/features/message-transforms
33 | Transforms []string `json:"transforms,omitempty"`
34 | Stream bool `json:"stream,omitempty"`
35 | // MaxTokens The maximum number of tokens that can be generated in the chat completion.
36 | // This value can be used to control costs for text generated via API.
37 | MaxTokens int `json:"max_tokens,omitempty"`
38 | Temperature float32 `json:"temperature,omitempty"`
39 | Seed *int `json:"seed,omitempty"`
40 | TopP float32 `json:"top_p,omitempty"`
41 | TopK int `json:"top_k,omitempty"`
42 | FrequencyPenalty float32 `json:"frequency_penalty,omitempty"`
43 | PresencePenalty float32 `json:"presence_penalty,omitempty"`
44 | RepetitionPenalty float32 `json:"repetition_penalty,omitempty"`
45 | // LogitBias is must be a token id string (specified by their token ID in the tokenizer), not a word string.
46 | // incorrect: `"logit_bias":{"You": 6}`, correct: `"logit_bias":{"1639": 6}`
47 | // refs: https://platform.openai.com/docs/api-reference/chat/create#chat/create-logit_bias
48 | LogitBias map[string]int `json:"logit_bias,omitempty"`
49 | TopLogProbs int `json:"top_logprobs,omitempty"`
50 | MinP float32 `json:"min_p,omitempty"`
51 | TopA float32 `json:"top_a,omitempty"`
52 | User string `json:"user,omitempty"`
53 | // For usage with the broadcast feature. Group related requests together (such as a conversation or agent workflow) by including the session_id field (up to 128 characters).
54 | // https://openrouter.ai/docs/guides/features/broadcast/overview#optional-trace-data
55 | SessionId string `json:"session_id,omitempty"`
56 | }
57 |
58 | type CompletionChoice struct {
59 | Index int `json:"index"`
60 | Text string `json:"text"`
61 | // Reasoning Used by all the other models
62 | Reasoning *string `json:"reasoning,omitempty"`
63 | // FinishReason
64 | // stop: API returned complete message,
65 | // or a message terminated by one of the stop sequences provided via the stop parameter
66 | // length: Incomplete model output due to max_tokens parameter or token limit
67 | // function_call: The model decided to call a function
68 | // content_filter: Omitted content due to a flag from our content filters
69 | // null: API response still in progress or incomplete
70 | FinishReason FinishReason `json:"finish_reason"`
71 | LogProbs *LogProbs `json:"logprobs,omitempty"`
72 | }
73 |
74 | // CompletionResponse represents a response structure for completion API.
75 | type CompletionResponse struct {
76 | ID string `json:"id"`
77 | Object string `json:"object"`
78 | Created int64 `json:"created"`
79 | Model string `json:"model"`
80 | Choices []CompletionChoice `json:"choices"`
81 | Citations []string `json:"citations"`
82 | Usage *Usage `json:"usage,omitempty"`
83 | SystemFingerprint string `json:"system_fingerprint"`
84 | }
85 |
86 | // CreateCompletion — API call to Create a completion for the prompt.
87 | func (c *Client) CreateCompletion(
88 | ctx context.Context,
89 | request CompletionRequest,
90 | ) (response CompletionResponse, err error) {
91 | if request.Stream {
92 | err = ErrCompletionStreamNotSupported
93 | return
94 | }
95 |
96 | if !isSupportingModel(completionsSuffix, request.Model) {
97 | err = ErrCompletionInvalidModel
98 | return
99 | }
100 |
101 | req, err := c.newRequest(
102 | ctx,
103 | http.MethodPost,
104 | c.fullURL(completionsSuffix),
105 | withBody(request),
106 | )
107 | if err != nil {
108 | return
109 | }
110 |
111 | err = c.sendRequest(req, &response)
112 | return
113 | }
114 |
115 | type CompletionStream struct {
116 | stream <-chan CompletionResponse
117 | done chan struct{}
118 | response *http.Response
119 | }
120 |
121 | // CreateCompletionStream — API call to Create a completion for the prompt with streaming.
122 | func (c *Client) CreateCompletionStream(
123 | ctx context.Context,
124 | request CompletionRequest,
125 | ) (*CompletionStream, error) {
126 | if !request.Stream {
127 | request.Stream = true
128 | }
129 |
130 | if !isSupportingModel(completionsSuffix, request.Model) {
131 | return nil, ErrCompletionInvalidModel
132 | }
133 |
134 | req, err := c.newRequest(
135 | ctx,
136 | http.MethodPost,
137 | c.fullURL(completionsSuffix),
138 | withBody(request),
139 | )
140 | if err != nil {
141 | return nil, err
142 | }
143 |
144 | req.Header.Set("Accept", "text/event-stream")
145 | req.Header.Set("Cache-Control", "no-cache")
146 | req.Header.Set("Connection", "keep-alive")
147 | req.Header.Set("Content-Type", "application/json")
148 |
149 | resp, err := c.config.HTTPClient.Do(req)
150 | if err != nil {
151 | return nil, err
152 | }
153 | if isFailureStatusCode(resp) {
154 | return nil, c.handleErrorResp(resp)
155 | }
156 |
157 | if resp.StatusCode != http.StatusOK {
158 | resp.Body.Close()
159 | return nil, errors.New("unexpected status code: " + resp.Status)
160 | }
161 |
162 | stream := make(chan CompletionResponse)
163 | done := make(chan struct{})
164 |
165 | go func() {
166 | defer close(stream)
167 | defer resp.Body.Close()
168 |
169 | reader := bufio.NewReader(resp.Body)
170 | for {
171 | select {
172 | case <-done:
173 | return
174 | case <-ctx.Done():
175 | slog.Info("Stream stopped due to context cancellation")
176 | return
177 | default:
178 | line, err := reader.ReadBytes('\n')
179 | if err != nil {
180 | if err == io.EOF {
181 | return
182 | }
183 | slog.Error("failed to read completion stream", "error", err)
184 | return
185 | }
186 | // If stream ended with done, stop immediately
187 | if strings.HasSuffix(string(line), "[DONE]\n") {
188 | return
189 | }
190 | // Ignore openrouter comments, empty lines
191 | if strings.HasPrefix(string(line), ": OPENROUTER PROCESSING") || string(line) == "\n" {
192 | continue
193 | }
194 | // Trim everything before json object from line
195 | line = bytes.TrimPrefix(line, []byte("data:"))
196 | // Decode object into a CompletionResponse
197 | var chunk CompletionResponse
198 | if err := json.Unmarshal(line, &chunk); err != nil {
199 | slog.Error("failed to decode completion stream", "error", err, "line", string(line))
200 | return
201 | }
202 | stream <- chunk
203 | }
204 | }
205 | }()
206 |
207 | return &CompletionStream{
208 | stream: stream,
209 | done: done,
210 | response: resp,
211 | }, nil
212 | }
213 |
214 | // Recv reads the next chunk from the stream.
215 | func (s *CompletionStream) Recv() (CompletionResponse, error) {
216 | select {
217 | case chunk, ok := <-s.stream:
218 | if !ok {
219 | return CompletionResponse{}, io.EOF
220 | }
221 | return chunk, nil
222 | case <-s.done:
223 | return CompletionResponse{}, io.EOF
224 | }
225 | }
226 |
227 | // Close terminates the stream and cleans up resources.
228 | func (s *CompletionStream) Close() {
229 | close(s.done)
230 | if s.response != nil {
231 | s.response.Body.Close()
232 | }
233 | }
234 |
--------------------------------------------------------------------------------
/sample_prompt_test.go:
--------------------------------------------------------------------------------
1 | package openrouter_test
2 |
3 | const testLongToken = `Chapter I
4 | The Extent of the Empire in the Age of the Antonines Introduction.
5 |
6 | The extent and military force of the empire in the age of the Antonines. In the second century of the Christian Era, the empire of Rome comprehended the fairest part of the earth and the most civilized portion of mankind. The frontiers of that extensive monarchy were guarded by ancient renown and disciplined valor. The gentle but powerful influence of laws and manners had gradually cemented the union of the provinces. Their peaceful inhabitants enjoyed and abused the advantages of wealth and luxury. The image of a free constitution was preserved with decent reverence: the Roman senate appeared to possess the sovereign authority, and devolved on the emperors all the executive powers of government. During a happy period of more than fourscore years [a.d. 98–180], the public administration was conducted by the virtue and abilities of Nerva, Trajan, Hadrian, and the two Antonines. It is the design of this, and of the two succeeding chapters, to describe the prosperous condition of their empire; and afterwards, from the death of Marcus Antoninus, to deduce the most important circumstances of its decline and fall, a revolution which will ever be remembered, and is still felt by the nations of the earth.
7 |
8 | The principal conquests of the Romans were achieved under the republic; and the emperors, for the most part, were satisfied with preserving those dominions which had been acquired by the policy of the senate, the active emulation of the consuls, and the martial enthusiasm of the people. The seven first centuries were filled with a rapid succession of triumphs; but it was reserved for Augustus to relinquish the ambitious design of subduing the whole earth, and to introduce a spirit of moderation into the public councils. Inclined to peace by his temper and situation, it was easy for him to discover that Rome, in her present exalted situation, had much less to hope than to fear from the chance of arms; and that, in the prosecution of remote wars, the undertaking became every day more difficult, the event more doubtful, and the possession more precarious, and less beneficial.
9 |
10 | Happily for the repose of mankind, the moderate system recommended by the wisdom of Augustus was adopted by the fears and vices of his immediate successors. Engaged in the pursuit of pleasure, or in the exercise of tyranny, the first Caesars seldom showed themselves to the armies, or to the provinces; nor were they disposed to suffer that those triumphs which their indolence neglected should be usurped by the conduct and valor of their lieutenants. The military fame of a subject was considered as an insolent invasion of the Imperial prerogative; and it became the duty, as well as interest, of every Roman general to guard the frontiers entrusted to his care without aspiring to conquests which might have proved no less fatal to himself than to the vanquished Barbarians.
11 |
12 | The only accession which the Roman empire received during the first century of the Christian Era was the province of Britain. In this single instance, the successors of Caesar and Augustus were persuaded to follow the example of the former, rather than the precept of the latter. The proximity of its situation to the coast of Gaul seemed to invite their arms; the pleasing though doubtful intelligence of a pearl fishery attracted their avarice; and as Britain was viewed in the light of a distinct and insulated world, the conquest scarcely formed any exception to the general system of continental measures. After a war of about forty years, undertaken by the most stupid, maintained by the most dissolute, and terminated by the most timid of all the emperors, the far greater part of the island submitted to the Roman yoke. The various tribes of Britain possessed valor without conduct, and the love of freedom without the spirit of union. They took up arms with savage fierceness; they laid them down, or turned them against each other, with wild inconsistency; and while they fought singly, they were successively subdued. Neither the fortitude of Caractacus, nor the despair of Boadicea, nor the fanaticism of the Druids could avert the slavery of their country, or resist the steady progress of the Imperial generals, who maintained the national glory when the throne was disgraced by the weakest, or the most vicious of mankind. Such was the state of the Roman frontiers, and such the maxims of Imperial policy, from the death of Augustus to the accession of Trajan. That virtuous and active prince had received the education of a soldier and possessed the talents of a general. The peaceful system of his predecessors was interrupted by scenes of war and conquest; and the legions, after a long interval, beheld a military emperor at their head. The first exploits of Trajan were against the Dacians, the most warlike of men, who dwelt beyond the Danube and who, during the reign of Domitian, had insulted with impunity the Majesty of Rome. To the strength and fierceness of Barbarians they added a contempt for life, which was derived from a warm persuasion of the immortality and transmigration of the soul. Decebalus, the Dacian king, approved himself a rival not unworthy of Trajan; nor did he despair of his own and the public fortune till, by the confession of his enemies, he had exhausted every resource both of valor and policy. This memorable war, with a very short suspension of hostilities, lasted five years [a.d. 101–106]; and as the emperor could exert, without control, the whole force of the state, it was terminated by an absolute submission of the Barbarians. The new province of Dacia . . . formed a second exception to the precept of Augustus.
13 |
14 | Trajan was ambitious of fame; and as long as mankind shall continue to bestow more liberal applause on their destroyers than on their benefactors, the thirst of military glory will ever be the vice of the most exalted characters. The praises of Alexander, transmitted by a succession of poets and historians, had kindled a dangerous emulation in the mind of Trajan. Like him, the Roman emperor undertook an expedition against the nations of the East; but he lamented with a sigh that his advanced age scarcely left him any hopes of equaling the renown of the son of Philip. Yet the success of Trajan, however transient, was rapid and specious. The degenerate Parthians, broken by intestine discord, fled before his arms. He descended the River Tigris in triumph, from the mountains of Armenia to the Persian Gulf. He enjoyed the honor of being the first, as he was the last, of the Roman generals who ever navigated that remote sea. His fleets ravaged the coast of Arabia; and Trajan vainly flattered himself that he was approaching towards the confines of India. Every day the astonished senate received the intelligence of new names and new nations that acknowledged his sway. They were informed that . . . the rich countries of Armenia, Mesopotamia, and Assyria were reduced into the state of provinces. But the death of Trajan soon clouded the splendid prospect; and it was justly to be dreaded that so many distant nations would throw off the unaccustomed yoke when they were no longer restrained by the powerful hand which had imposed it.
15 |
16 | It was an ancient tradition that when the Capitol was founded by one of the Roman kings, the god Terminus (who presided over boundaries, and was represented, according to the fashion of that age, by a large stone) alone, among all the inferior deities, refused to yield his place to Jupiter himself. A favorable inference was drawn from his obstinacy, which was interpreted by the augurs as a sure presage that the boundaries of the Roman power would never recede. During many ages, the prediction, as it is usual, contributed to its own accomplishment. But though Terminus had resisted the majesty of Jupiter, he submitted to the authority of the emperor Hadrian. The resignation of all the eastern conquests of Trajan was the first measure of his reign . . . ; and, in compliance with the precept of Augustus, once more established the Euphrates as the frontier of the empire. Censure, which arraigns the public actions and the private motives of princes, has ascribed to envy a conduct which might be attributed to the prudence and moderation of Hadrian. The various character of that emperor, capable, by turns, of the meanest and the most generous sentiments, may afford some color to the suspicion. It was, however, scarcely in his power to place the superiority of his predecessor in a more conspicuous light than by thus confessing himself unequal to the task of defending the conquests of Trajan.
17 |
18 | `
19 |
--------------------------------------------------------------------------------
/client_test.go:
--------------------------------------------------------------------------------
1 | package openrouter_test
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "os"
8 | "strings"
9 | "testing"
10 | "time"
11 |
12 | openrouter "github.com/revrost/go-openrouter"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | const FreeModel = "deepseek/deepseek-r1-0528-qwen3-8b:free"
17 | const OSSFreeModel = "openai/gpt-oss-20b:free"
18 |
19 | // Test client setup
20 | func createTestClient(t *testing.T) *openrouter.Client {
21 | t.Helper()
22 | token := os.Getenv("OPENROUTER_API_KEY")
23 | if token == "" {
24 | t.Skip("Skipping integration test: OPENROUTER_API_KEY not set")
25 | }
26 |
27 | // Add optional headers if needed
28 | return openrouter.NewClient(token,
29 | openrouter.WithXTitle("Integration Tests"),
30 | openrouter.WithHTTPReferer("https://github.com/revrost/go-openrouter"),
31 | )
32 | }
33 |
34 | func TestCreateChatCompletion(t *testing.T) {
35 | client := createTestClient(t)
36 |
37 | tests := []struct {
38 | name string
39 | request openrouter.ChatCompletionRequest
40 | wantErr bool
41 | validate func(*testing.T, openrouter.ChatCompletionResponse)
42 | }{
43 | {
44 | name: "basic completion",
45 | request: openrouter.ChatCompletionRequest{
46 | Model: FreeModel,
47 | Messages: []openrouter.ChatCompletionMessage{
48 | {
49 | Role: openrouter.ChatMessageRoleUser,
50 | Content: openrouter.Content{Text: "Hello! Respond with just 'world'"},
51 | },
52 | },
53 | },
54 | validate: func(t *testing.T, resp openrouter.ChatCompletionResponse) {
55 | if len(resp.Choices) == 0 {
56 | t.Error("Expected at least one choice in response")
57 | }
58 | if len(resp.Choices[0].Message.Content.Text) > 10 {
59 | t.Errorf("Unexpected response: '%s' expected 'world'", resp.Choices[0].Message.Content.Text)
60 | }
61 | },
62 | },
63 | {
64 | name: "invalid model",
65 | request: openrouter.ChatCompletionRequest{
66 | Model: "invalid-model",
67 | Messages: []openrouter.ChatCompletionMessage{
68 | {Role: openrouter.ChatMessageRoleUser, Content: openrouter.Content{Text: "Hello"}},
69 | },
70 | },
71 | wantErr: true,
72 | },
73 | {
74 | name: "streaming not supported",
75 | request: openrouter.ChatCompletionRequest{
76 | Model: openrouter.LiquidLFM7B,
77 | Stream: true,
78 | Messages: []openrouter.ChatCompletionMessage{
79 | {Role: openrouter.ChatMessageRoleUser, Content: openrouter.Content{Text: "Hello"}},
80 | },
81 | },
82 | wantErr: true,
83 | },
84 | }
85 |
86 | for _, tt := range tests {
87 | t.Run(tt.name, func(t *testing.T) {
88 | ctx := context.Background()
89 | resp, err := client.CreateChatCompletion(ctx, tt.request)
90 |
91 | if (err != nil) != tt.wantErr {
92 | t.Errorf("CreateChatCompletion() error = %v, wantErr %v", err, tt.wantErr)
93 | return
94 | }
95 |
96 | if tt.validate != nil {
97 | tt.validate(t, resp)
98 | }
99 | })
100 | }
101 | }
102 |
103 | func TestCreateCompletion(t *testing.T) {
104 | client := createTestClient(t)
105 |
106 | tests := []struct {
107 | name string
108 | request openrouter.CompletionRequest
109 | wantErr bool
110 | validate func(*testing.T, openrouter.CompletionResponse)
111 | }{
112 | {
113 | name: "basic completion",
114 | request: openrouter.CompletionRequest{
115 | Model: "nousresearch/hermes-4-70b",
116 | Prompt: "Hello! Respond with just 'world'",
117 | },
118 | validate: func(t *testing.T, resp openrouter.CompletionResponse) {
119 | if len(resp.Choices) == 0 {
120 | t.Error("Expected at least one choice in response")
121 | }
122 | if !strings.Contains(resp.Choices[0].Text, "world") {
123 | t.Errorf("Unexpected response: '%s' expected 'world'", resp.Choices[0].Text)
124 | }
125 | },
126 | },
127 | {
128 | name: "invalid model",
129 | request: openrouter.CompletionRequest{
130 | Model: "invalid-model",
131 | Prompt: "Hello",
132 | },
133 | wantErr: true,
134 | },
135 | {
136 | name: "streaming not supported",
137 | request: openrouter.CompletionRequest{
138 | Model: openrouter.LiquidLFM7B,
139 | Stream: true,
140 | Prompt: "Hello",
141 | },
142 | wantErr: true,
143 | },
144 | }
145 |
146 | for _, tt := range tests {
147 | t.Run(tt.name, func(t *testing.T) {
148 | ctx := context.Background()
149 | resp, err := client.CreateCompletion(ctx, tt.request)
150 |
151 | if (err != nil) != tt.wantErr {
152 | t.Errorf("CreateCompletion() error = %v, wantErr %v", err, tt.wantErr)
153 | return
154 | }
155 |
156 | if tt.validate != nil {
157 | tt.validate(t, resp)
158 | }
159 | })
160 | }
161 | }
162 |
163 | func TestExplicitPromptCachingApplies(t *testing.T) {
164 | t.Skip("Only run this test locally")
165 | client := createTestClient(t)
166 |
167 | message := openrouter.ChatCompletionMessage{
168 | Role: openrouter.ChatMessageRoleSystem,
169 | Content: openrouter.Content{
170 | Multi: []openrouter.ChatMessagePart{
171 | {
172 | Type: openrouter.ChatMessagePartTypeText,
173 | Text: testLongToken,
174 | CacheControl: &openrouter.CacheControl{Type: "ephemeral"},
175 | },
176 | },
177 | },
178 | }
179 | userMessage := openrouter.ChatCompletionMessage{
180 | Role: openrouter.ChatMessageRoleUser,
181 | Content: openrouter.Content{
182 | Multi: []openrouter.ChatMessagePart{
183 | {
184 | Type: openrouter.ChatMessagePartTypeText,
185 | Text: "Who was augustus based on the text?",
186 | CacheControl: &openrouter.CacheControl{Type: "ephemeral"},
187 | },
188 | },
189 | },
190 | }
191 | request := openrouter.ChatCompletionRequest{
192 | Model: "google/gemini-2.5-flash-preview-05-20",
193 | Messages: []openrouter.ChatCompletionMessage{
194 | message,
195 | userMessage,
196 | },
197 | Usage: &openrouter.IncludeUsage{
198 | Include: true,
199 | },
200 | }
201 | px, _ := json.MarshalIndent(request, "", "\t")
202 | fmt.Printf("request :\n %s\n", string(px))
203 | response, err := client.CreateChatCompletion(context.Background(), request)
204 | b, _ := json.MarshalIndent(response, "", "\t")
205 | fmt.Printf("response :\n %s\n", string(b))
206 |
207 | require.NoError(t, err)
208 | }
209 |
210 | func TestUsageAccounting(t *testing.T) {
211 | client := createTestClient(t)
212 | request := openrouter.ChatCompletionRequest{
213 | Model: FreeModel,
214 | Messages: []openrouter.ChatCompletionMessage{
215 | openrouter.SystemMessage("You are a helpful assistant."),
216 | openrouter.UserMessage("How are you?"),
217 | },
218 | Usage: &openrouter.IncludeUsage{
219 | Include: true,
220 | },
221 | }
222 |
223 | response, err := client.CreateChatCompletion(context.Background(), request)
224 | require.NoError(t, err)
225 |
226 | usage := response.Usage
227 | require.NotNil(t, usage)
228 | require.NotNil(t, usage.PromptTokens)
229 | require.NotNil(t, usage.CompletionTokens)
230 | require.NotNil(t, usage.TotalTokens)
231 | }
232 |
233 | func TestAuthFailure(t *testing.T) {
234 | // Test with invalid token
235 | client := openrouter.NewClient("invalid-token")
236 |
237 | _, err := client.CreateChatCompletion(context.Background(), openrouter.ChatCompletionRequest{
238 | Model: openrouter.LiquidLFM7B,
239 | Messages: []openrouter.ChatCompletionMessage{
240 | {Role: openrouter.ChatMessageRoleUser, Content: openrouter.Content{Text: "Hello"}},
241 | },
242 | })
243 |
244 | if err == nil {
245 | t.Error("Expected authentication error, got nil")
246 | }
247 | }
248 |
249 | func TestProviderError(t *testing.T) {
250 | client := createTestClient(t)
251 |
252 | ctx := context.Background()
253 | _, err := client.CreateChatCompletion(ctx, openrouter.ChatCompletionRequest{
254 | Model: "openai/gpt-5-nano",
255 | Messages: []openrouter.ChatCompletionMessage{
256 | {
257 | Role: openrouter.ChatMessageRoleUser,
258 | Content: openrouter.Content{Text: "This will always fail with a provider error, because openai requires the message to contain the word j-s-o-n."},
259 | },
260 | },
261 | ResponseFormat: &openrouter.ChatCompletionResponseFormat{
262 | Type: openrouter.ChatCompletionResponseFormatTypeJSONObject,
263 | },
264 | })
265 |
266 | if err == nil {
267 | t.Error("Expected api error, got nil")
268 | }
269 |
270 | apiErr, ok := err.(*openrouter.APIError)
271 | if !ok {
272 | t.Errorf("Expected api error, got %T", err)
273 | }
274 |
275 | if msg := apiErr.Error(); msg != "provider error, code: 400, message: Response input messages must contain the word 'json' in some form to use 'text.format' of type 'json_object'." {
276 | t.Errorf("Expected provider error, got %v", msg)
277 | }
278 | }
279 |
280 | func TestGetGeneration(t *testing.T) {
281 | client := createTestClient(t)
282 |
283 | ctx := context.Background()
284 |
285 | request := openrouter.ChatCompletionRequest{
286 | Model: OSSFreeModel,
287 | Messages: []openrouter.ChatCompletionMessage{
288 | openrouter.SystemMessage("You are a helpful assistant."),
289 | openrouter.UserMessage("How are you?"),
290 | },
291 | Provider: &openrouter.ChatProvider{
292 | Only: []string{"atlas-cloud/fp8"},
293 | },
294 | }
295 |
296 | response, err := client.CreateChatCompletion(ctx, request)
297 | require.NoError(t, err)
298 |
299 | // openrouter takes a second to store it (removing this causes it to fail)
300 | time.Sleep(1 * time.Second)
301 |
302 | generation, err := client.GetGeneration(ctx, response.ID)
303 | require.NoError(t, err)
304 |
305 | require.Equal(t, generation.ID, response.ID)
306 | }
307 |
308 | func TestListModels(t *testing.T) {
309 | client := createTestClient(t)
310 |
311 | models, err := client.ListModels(context.Background())
312 | require.NoError(t, err)
313 |
314 | require.NotEmpty(t, models)
315 | require.NotEmpty(t, models[0].ID)
316 | }
317 |
318 | func TestListUserModels(t *testing.T) {
319 | client := createTestClient(t)
320 |
321 | models, err := client.ListUserModels(context.Background())
322 | require.NoError(t, err)
323 |
324 | require.NotEmpty(t, models)
325 | require.NotEmpty(t, models[0].ID)
326 | }
327 |
328 | func TestListEmbeddingsModels(t *testing.T) {
329 | client := createTestClient(t)
330 |
331 | models, err := client.ListEmbeddingsModels(context.Background())
332 | require.NoError(t, err)
333 |
334 | require.NotEmpty(t, models)
335 | require.NotEmpty(t, models[0].ID)
336 | }
337 |
--------------------------------------------------------------------------------
/jsonschema/json_test.go:
--------------------------------------------------------------------------------
1 | package jsonschema_test
2 |
3 | import (
4 | "encoding/json"
5 | "reflect"
6 | "testing"
7 |
8 | "github.com/revrost/go-openrouter/jsonschema"
9 | )
10 |
11 | func TestDefinition_MarshalJSON(t *testing.T) {
12 | tests := []struct {
13 | name string
14 | def jsonschema.Definition
15 | want string
16 | }{
17 | {
18 | name: "Test with empty Definition",
19 | def: jsonschema.Definition{},
20 | want: `{}`,
21 | },
22 | {
23 | name: "Test with Definition properties set",
24 | def: jsonschema.Definition{
25 | Type: jsonschema.String,
26 | Description: "A string type",
27 | Properties: map[string]jsonschema.Definition{
28 | "name": {
29 | Type: jsonschema.String,
30 | },
31 | },
32 | },
33 | want: `{
34 | "type":"string",
35 | "description":"A string type",
36 | "properties":{
37 | "name":{
38 | "type":"string"
39 | }
40 | }
41 | }`,
42 | },
43 | {
44 | name: "Test with nested Definition properties",
45 | def: jsonschema.Definition{
46 | Type: jsonschema.Object,
47 | Properties: map[string]jsonschema.Definition{
48 | "user": {
49 | Type: jsonschema.Object,
50 | Properties: map[string]jsonschema.Definition{
51 | "name": {
52 | Type: jsonschema.String,
53 | },
54 | "age": {
55 | Type: jsonschema.Integer,
56 | },
57 | },
58 | },
59 | },
60 | },
61 | want: `{
62 | "type":"object",
63 | "properties":{
64 | "user":{
65 | "type":"object",
66 | "properties":{
67 | "name":{
68 | "type":"string"
69 | },
70 | "age":{
71 | "type":"integer"
72 | }
73 | }
74 | }
75 | }
76 | }`,
77 | },
78 | {
79 | name: "Test with complex nested Definition",
80 | def: jsonschema.Definition{
81 | Type: jsonschema.Object,
82 | Properties: map[string]jsonschema.Definition{
83 | "user": {
84 | Type: jsonschema.Object,
85 | Properties: map[string]jsonschema.Definition{
86 | "name": {
87 | Type: jsonschema.String,
88 | },
89 | "age": {
90 | Type: jsonschema.Integer,
91 | },
92 | "address": {
93 | Type: jsonschema.Object,
94 | Properties: map[string]jsonschema.Definition{
95 | "city": {
96 | Type: jsonschema.String,
97 | },
98 | "country": {
99 | Type: jsonschema.String,
100 | },
101 | },
102 | },
103 | },
104 | },
105 | },
106 | },
107 | want: `{
108 | "type":"object",
109 | "properties":{
110 | "user":{
111 | "type":"object",
112 | "properties":{
113 | "name":{
114 | "type":"string"
115 | },
116 | "age":{
117 | "type":"integer"
118 | },
119 | "address":{
120 | "type":"object",
121 | "properties":{
122 | "city":{
123 | "type":"string"
124 | },
125 | "country":{
126 | "type":"string"
127 | }
128 | }
129 | }
130 | }
131 | }
132 | }
133 | }`,
134 | },
135 | {
136 | name: "Test with Array type Definition",
137 | def: jsonschema.Definition{
138 | Type: jsonschema.Array,
139 | Items: &jsonschema.Definition{
140 | Type: jsonschema.String,
141 | },
142 | Properties: map[string]jsonschema.Definition{
143 | "name": {
144 | Type: jsonschema.String,
145 | },
146 | },
147 | },
148 | want: `{
149 | "type":"array",
150 | "items":{
151 | "type":"string"
152 | },
153 | "properties":{
154 | "name":{
155 | "type":"string"
156 | }
157 | }
158 | }`,
159 | },
160 | }
161 |
162 | for _, tt := range tests {
163 | t.Run(tt.name, func(t *testing.T) {
164 | wantBytes := []byte(tt.want)
165 | var want map[string]interface{}
166 | err := json.Unmarshal(wantBytes, &want)
167 | if err != nil {
168 | t.Errorf("Failed to Unmarshal JSON: error = %v", err)
169 | return
170 | }
171 |
172 | got := structToMap(t, tt.def)
173 | gotPtr := structToMap(t, &tt.def)
174 |
175 | if !reflect.DeepEqual(got, want) {
176 | t.Errorf("MarshalJSON() got = %v, want %v", got, want)
177 | }
178 | if !reflect.DeepEqual(gotPtr, want) {
179 | t.Errorf("MarshalJSON() gotPtr = %v, want %v", gotPtr, want)
180 | }
181 | })
182 | }
183 | }
184 |
185 | func TestStructToSchema(t *testing.T) {
186 | tests := []struct {
187 | name string
188 | in any
189 | want string
190 | }{
191 | {
192 | name: "Test with empty struct",
193 | in: struct{}{},
194 | want: `{
195 | "type":"object",
196 | "additionalProperties":false
197 | }`,
198 | },
199 | {
200 | name: "Test with struct containing many fields",
201 | in: struct {
202 | Name string `json:"name"`
203 | Age int `json:"age"`
204 | Active bool `json:"active"`
205 | Height float64 `json:"height"`
206 | Cities []struct {
207 | Name string `json:"name"`
208 | State string `json:"state"`
209 | } `json:"cities"`
210 | }{
211 | Name: "John Doe",
212 | Age: 30,
213 | Cities: []struct {
214 | Name string `json:"name"`
215 | State string `json:"state"`
216 | }{
217 | {Name: "New York", State: "NY"},
218 | {Name: "Los Angeles", State: "CA"},
219 | },
220 | },
221 | want: `{
222 | "type":"object",
223 | "properties":{
224 | "name":{
225 | "type":"string"
226 | },
227 | "age":{
228 | "type":"integer"
229 | },
230 | "active":{
231 | "type":"boolean"
232 | },
233 | "height":{
234 | "type":"number"
235 | },
236 | "cities":{
237 | "type":"array",
238 | "items":{
239 | "additionalProperties":false,
240 | "type":"object",
241 | "properties":{
242 | "name":{
243 | "type":"string"
244 | },
245 | "state":{
246 | "type":"string"
247 | }
248 | },
249 | "required":["name","state"]
250 | }
251 | }
252 | },
253 | "required":["name","age","active","height","cities"],
254 | "additionalProperties":false
255 | }`,
256 | },
257 | {
258 | name: "Test with description tag",
259 | in: struct {
260 | Name string `json:"name" description:"The name of the person"`
261 | }{
262 | Name: "John Doe",
263 | },
264 | want: `{
265 | "type":"object",
266 | "properties":{
267 | "name":{
268 | "type":"string",
269 | "description":"The name of the person"
270 | }
271 | },
272 | "required":["name"],
273 | "additionalProperties":false
274 | }`,
275 | },
276 | {
277 | name: "Test with required tag",
278 | in: struct {
279 | Name string `json:"name" required:"false"`
280 | }{
281 | Name: "John Doe",
282 | },
283 | want: `{
284 | "type":"object",
285 | "properties":{
286 | "name":{
287 | "type":"string"
288 | }
289 | },
290 | "additionalProperties":false
291 | }`,
292 | },
293 | {
294 | name: "Test with enum tag",
295 | in: struct {
296 | Color string `json:"color" enum:"red,green,blue"`
297 | }{
298 | Color: "red",
299 | },
300 | want: `{
301 | "type":"object",
302 | "properties":{
303 | "color":{
304 | "type":"string",
305 | "enum":["red","green","blue"]
306 | }
307 | },
308 | "required":["color"],
309 | "additionalProperties":false
310 | }`,
311 | },
312 | {
313 | name: "Test with nullable tag",
314 | in: struct {
315 | Name *string `json:"name" nullable:"true"`
316 | }{
317 | Name: nil,
318 | },
319 | want: `{
320 |
321 | "type":"object",
322 | "properties":{
323 | "name":{
324 | "type":"string",
325 | "nullable":true
326 | }
327 | },
328 | "required":["name"],
329 | "additionalProperties":false
330 | }`,
331 | },
332 | {
333 | name: "Test with exclude mark",
334 | in: struct {
335 | Name string `json:"-"`
336 | }{
337 | Name: "Name",
338 | },
339 | want: `{
340 | "type":"object",
341 | "additionalProperties":false
342 | }`,
343 | },
344 | {
345 | name: "Test with no json tag",
346 | in: struct {
347 | Name string
348 | }{
349 | Name: "",
350 | },
351 | want: `{
352 | "type":"object",
353 | "properties":{
354 | "Name":{
355 | "type":"string"
356 | }
357 | },
358 | "required":["Name"],
359 | "additionalProperties":false
360 | }`,
361 | },
362 | {
363 | name: "Test with omitempty tag",
364 | in: struct {
365 | Name string `json:"name,omitempty"`
366 | }{
367 | Name: "",
368 | },
369 | want: `{
370 | "type":"object",
371 | "properties":{
372 | "name":{
373 | "type":"string"
374 | }
375 | },
376 | "additionalProperties":false
377 | }`,
378 | },
379 | {
380 | name: "Test with skipschema tag",
381 | in: struct {
382 | Name string `json:"name,omitempty"`
383 | ForbiddenMap map[string]any `json:"forbidden_map,omitempty" skipschema:"true"`
384 | }{
385 | Name: "",
386 | ForbiddenMap: map[string]any{},
387 | },
388 | want: `{
389 | "type":"object",
390 | "properties":{
391 | "name":{
392 | "type":"string"
393 | }
394 | },
395 | "additionalProperties":false
396 | }`,
397 | },
398 | }
399 |
400 | for _, tt := range tests {
401 | t.Run(tt.name, func(t *testing.T) {
402 | wantBytes := []byte(tt.want)
403 |
404 | schema, err := jsonschema.GenerateSchemaForType(tt.in)
405 | if err != nil {
406 | t.Errorf("Failed to generate schema: error = %v", err)
407 | return
408 | }
409 |
410 | var want map[string]any
411 | err = json.Unmarshal(wantBytes, &want)
412 | if err != nil {
413 | t.Errorf("Failed to Unmarshal JSON: error = %v", err)
414 | return
415 | }
416 |
417 | got := structToMap(t, schema)
418 | gotPtr := structToMap(t, &schema)
419 |
420 | if !reflect.DeepEqual(got, want) {
421 | t.Errorf("MarshalJSON() got = %v, want %v", got, want)
422 | }
423 | if !reflect.DeepEqual(gotPtr, want) {
424 | t.Errorf("MarshalJSON() gotPtr = %v, want %v", gotPtr, want)
425 | }
426 | })
427 | }
428 | }
429 |
430 | func structToMap(t *testing.T, v any) map[string]any {
431 | t.Helper()
432 | gotBytes, err := json.Marshal(v)
433 | if err != nil {
434 | t.Errorf("Failed to Marshal JSON: error = %v", err)
435 | return nil
436 | }
437 |
438 | var got map[string]any
439 | err = json.Unmarshal(gotBytes, &got)
440 | if err != nil {
441 | t.Errorf("Failed to Unmarshal JSON: error = %v", err)
442 | return nil
443 | }
444 | return got
445 | }
446 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/chat.go:
--------------------------------------------------------------------------------
1 | package openrouter
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "context"
7 | "encoding/json"
8 | "errors"
9 | "io"
10 | "log/slog"
11 | "net/http"
12 | "strings"
13 | )
14 |
15 | const (
16 | GPT4o = "openai/chatgpt-4o-latest"
17 | DeepseekV3 = "deepseek/deepseek-chat"
18 | DeepseekR1 = "deepseek/deepseek-r1"
19 | DeepseekR1DistillLlama = "deepseek/deepseek-r1-distill-llama-70b"
20 | LiquidLFM7B = "liquid/lfm-7b"
21 | Phi3Mini = "microsoft/phi-3-mini-128k-instruct:free"
22 | GeminiFlashExp = "google/gemini-2.0-flash-exp:free"
23 | GeminiProExp = "google/gemini-pro-1.5-exp"
24 | GeminiFlash8B = "google/gemini-flash-1.5-8b"
25 | GPT4oMini = "openai/gpt-4o-mini"
26 | )
27 |
28 | // Chat message role defined by the Openrouter API.
29 | const (
30 | ChatMessageRoleSystem = "system"
31 | ChatMessageRoleUser = "user"
32 | ChatMessageRoleAssistant = "assistant"
33 | ChatMessageRoleFunction = "function"
34 | ChatMessageRoleTool = "tool"
35 | )
36 | const chatCompletionsSuffix = "/chat/completions"
37 |
38 | var (
39 | ErrChatCompletionInvalidModel = errors.New("this model is not supported with this method, please use CreateCompletion client method instead") //nolint:lll
40 | ErrChatCompletionStreamNotSupported = errors.New("streaming is not supported with this method, please use CreateChatCompletion") //nolint:lll
41 | ErrContentFieldsMisused = errors.New("can't use both Content and MultiContent properties simultaneously")
42 | )
43 |
44 | type ChatCompletionReasoning struct {
45 | // Effort The prompt that was used to generate the reasoning. [high, medium, low]
46 | Effort *string `json:"prompt,omitempty"`
47 |
48 | // MaxTokens cannot be simultaneously used with effort.
49 | MaxTokens *int `json:"max_tokens,omitempty"`
50 |
51 | // Exclude defaults to false.
52 | Exclude *bool `json:"exclude,omitempty"`
53 |
54 | // Or enable reasoning with the default parameters:
55 | // Default: inferred from `effort` or `max_tokens`
56 | Enabled *bool `json:"enabled,omitempty"`
57 | }
58 |
59 | type PluginID string
60 |
61 | const (
62 | // Processing PDFs: https://openrouter.ai/docs/features/images-and-pdfs#processing-pdfs
63 | PluginIDFileParser PluginID = "file-parser"
64 | // Web search plugin: https://openrouter.ai/docs/features/web-search
65 | PluginIDWeb PluginID = "web"
66 | )
67 |
68 | type PDFEngine string
69 |
70 | const (
71 | // Best for scanned documents or PDFs with images ($2 per 1,000 pages).
72 | PDFEngineMistralOCR PDFEngine = "mistral-ocr"
73 | // Best for well-structured PDFs with clear text content (Free).
74 | PDFEnginePDFText PDFEngine = "pdf-text"
75 | // Only available for models that support file input natively (charged as input tokens).
76 | PDFEngineNative PDFEngine = "native"
77 | )
78 |
79 | type ChatCompletionPlugin struct {
80 | ID PluginID `json:"id"`
81 | PDF *PDFPlugin `json:"pdf,omitempty"`
82 | MaxResults *int `json:"max_results,omitempty"`
83 | }
84 |
85 | type PDFPlugin struct {
86 | Engine string `json:"engine"`
87 | }
88 |
89 | type ChatCompletionModality string
90 |
91 | const (
92 | ModalityText ChatCompletionModality = "text"
93 | ModalityImage ChatCompletionModality = "image"
94 | )
95 |
96 | type ChatCompletionAspectRatio string
97 |
98 | const (
99 | AspectRatio1x1 ChatCompletionAspectRatio = "1:1"
100 | AspectRatio2x3 ChatCompletionAspectRatio = "2:3"
101 | AspectRatio3x2 ChatCompletionAspectRatio = "3:2"
102 | AspectRatio3x4 ChatCompletionAspectRatio = "3:4"
103 | AspectRatio4x3 ChatCompletionAspectRatio = "4:3"
104 | AspectRatio4x5 ChatCompletionAspectRatio = "4:5"
105 | AspectRatio5x4 ChatCompletionAspectRatio = "5:4"
106 | AspectRatio9x16 ChatCompletionAspectRatio = "9:16"
107 | AspectRatio16x9 ChatCompletionAspectRatio = "16:9"
108 | AspectRatio21x9 ChatCompletionAspectRatio = "21:9"
109 | )
110 |
111 | type ChatCompletionImageSize string
112 |
113 | const (
114 | ImageSize1K ChatCompletionImageSize = "1K"
115 | ImageSize2K ChatCompletionImageSize = "2K"
116 | ImageSize4K ChatCompletionImageSize = "4K"
117 | )
118 |
119 | // ChatCompletionImageConfig is used to configure the image generation.
120 | // https://openrouter.ai/docs/features/multimodal/image-generation#image-aspect-ratio-configuration
121 | // Default '1:1' → 1024×1024 (default)
122 | type ChatCompletionImageConfig struct {
123 | AspectRatio ChatCompletionAspectRatio `json:"aspect_ratio,omitempty"`
124 | ImageSize ChatCompletionImageSize `json:"image_size,omitempty"`
125 | }
126 |
127 | type ChatCompletionRequest struct {
128 | Model string `json:"model,omitempty"`
129 | // Optional model fallbacks: https://openrouter.ai/docs/features/model-routing#the-models-parameter
130 | Models []string `json:"models,omitempty"`
131 | Provider *ChatProvider `json:"provider,omitempty"`
132 | Messages []ChatCompletionMessage `json:"messages"`
133 |
134 | Reasoning *ChatCompletionReasoning `json:"reasoning,omitempty"`
135 |
136 | Plugins []ChatCompletionPlugin `json:"plugins,omitempty"`
137 | Modalities []ChatCompletionModality `json:"modalities,omitempty"`
138 |
139 | ImageConfig *ChatCompletionImageConfig `json:"image_config,omitempty"`
140 |
141 | // MaxTokens The maximum number of tokens that can be generated in the chat completion.
142 | // This value can be used to control costs for text generated via API.
143 | MaxTokens int `json:"max_tokens,omitempty"`
144 | // MaxCompletionTokens Upper bound for completion tokens, supported for OpenAI API compliance.
145 | // Prefer "max_tokens" for limiting output in new integrations.
146 | // refs: https://platform.openai.com/docs/api-reference/chat/create#chat-create-max_completion_tokens
147 | MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
148 | Temperature float32 `json:"temperature,omitempty"`
149 | TopP float32 `json:"top_p,omitempty"`
150 | TopK int `json:"top_k,omitempty"`
151 | TopA float32 `json:"top_a,omitempty"`
152 | N int `json:"n,omitempty"`
153 | Stream bool `json:"stream,omitempty"`
154 | Stop []string `json:"stop,omitempty"`
155 | PresencePenalty float32 `json:"presence_penalty,omitempty"`
156 | RepetitionPenalty float32 `json:"repetition_penalty,omitempty"`
157 | ResponseFormat *ChatCompletionResponseFormat `json:"response_format,omitempty"`
158 | Seed *int `json:"seed,omitempty"`
159 | MinP float32 `json:"min_p,omitempty"`
160 | FrequencyPenalty float32 `json:"frequency_penalty,omitempty"`
161 | // LogitBias is must be a token id string (specified by their token ID in the tokenizer), not a word string.
162 | // incorrect: `"logit_bias":{"You": 6}`, correct: `"logit_bias":{"1639": 6}`
163 | // refs: https://platform.openai.com/docs/api-reference/chat/create#chat/create-logit_bias
164 | LogitBias map[string]int `json:"logit_bias,omitempty"`
165 | // LogProbs indicates whether to return log probabilities of the output tokens or not.
166 | // If true, returns the log probabilities of each output token returned in the content of message.
167 | // This option is currently not available on the gpt-4-vision-preview model.
168 | LogProbs bool `json:"logprobs,omitempty"`
169 | // TopLogProbs is an integer between 0 and 5 specifying the number of most likely tokens to return at each
170 | // token position, each with an associated log probability.
171 | // logprobs must be set to true if this parameter is used.
172 | TopLogProbs int `json:"top_logprobs,omitempty"`
173 | User string `json:"user,omitempty"`
174 | // For usage with the broadcast feature. Group related requests together (such as a conversation or agent workflow) by including the session_id field (up to 128 characters).
175 | // https://openrouter.ai/docs/guides/features/broadcast/overview#optional-trace-data
176 | SessionId string `json:"session_id,omitempty"`
177 | // Deprecated: use Tools instead.
178 | Functions []FunctionDefinition `json:"functions,omitempty"`
179 | // Deprecated: use ToolChoice instead.
180 | FunctionCall any `json:"function_call,omitempty"`
181 | Tools []Tool `json:"tools,omitempty"`
182 | // This can be either a string or an ToolChoice object.
183 | ToolChoice any `json:"tool_choice,omitempty"`
184 | // Options for streaming response. Only set this when you set stream: true.
185 | StreamOptions *StreamOptions `json:"stream_options,omitempty"`
186 | // Disable the default behavior of parallel tool calls by setting it: false.
187 | ParallelToolCalls any `json:"parallel_tool_calls,omitempty"`
188 | // Store can be set to true to store the output of this completion request for use in distillations and evals.
189 | // https://platform.openai.com/docs/api-reference/chat/create#chat-create-store
190 | Store bool `json:"store,omitempty"`
191 | // Metadata to store with the completion.
192 | Metadata map[string]string `json:"metadata,omitempty"`
193 | // Apply message transforms
194 | // https://openrouter.ai/docs/features/message-transforms
195 | Transforms []string `json:"transforms,omitempty"`
196 | // Optional web search options
197 | // https://openrouter.ai/docs/features/web-search#specifying-search-context-size
198 | WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
199 |
200 | Usage *IncludeUsage `json:"usage,omitempty"`
201 | }
202 |
203 | type SearchContextSize string
204 |
205 | const (
206 | SearchContextSizeLow SearchContextSize = "low"
207 | SearchContextSizeMedium SearchContextSize = "medium"
208 | SearchContextSizeHigh SearchContextSize = "high"
209 | )
210 |
211 | type WebSearchOptions struct {
212 | SearchContextSize SearchContextSize `json:"search_context_size"`
213 | }
214 |
215 | type IncludeUsage struct {
216 | Include bool `json:"include"`
217 | }
218 |
219 | type DataCollection string
220 |
221 | const (
222 | DataCollectionAllow DataCollection = "allow"
223 | DataCollectionDeny DataCollection = "deny"
224 | )
225 |
226 | type ProviderSorting string
227 |
228 | const (
229 | ProviderSortingPrice ProviderSorting = "price"
230 | ProviderSortingThroughput ProviderSorting = "throughput"
231 | ProviderSortingLatency ProviderSorting = "latency"
232 | )
233 |
234 | // Provider Routing: https://openrouter.ai/docs/features/provider-routing
235 | type ChatProvider struct {
236 | // The order of the providers in the list determines the order in which they are called.
237 | Order []string `json:"order,omitempty"`
238 | // Allow fallbacks to other providers if the primary provider fails. Default: true
239 | AllowFallbacks *bool `json:"allow_fallbacks,omitempty"`
240 | // Only use providers that support all parameters in your request.
241 | RequireParameters bool `json:"require_parameters,omitempty"`
242 | // Control whether to use providers that may store data.
243 | DataCollection DataCollection `json:"data_collection,omitempty"`
244 | // List of provider slugs to allow for this request.
245 | Only []string `json:"only,omitempty"`
246 | // List of provider slugs to skip for this request.
247 | Ignore []string `json:"ignore,omitempty"`
248 | // List of quantization levels to filter by (e.g. ["int4", "int8"]).
249 | Quantizations []string `json:"quantizations,omitempty"`
250 | // Sort providers by price or throughput. (e.g. "price" or "throughput").
251 | Sort ProviderSorting `json:"sort,omitempty"`
252 | }
253 |
254 | // ChatCompletionResponse represents a response structure for chat completion API.
255 | type ChatCompletionResponse struct {
256 | ID string `json:"id"`
257 | Object string `json:"object"`
258 | Created int64 `json:"created"`
259 | Model string `json:"model"`
260 | Choices []ChatCompletionChoice `json:"choices"`
261 | Citations []string `json:"citations"`
262 | Usage *Usage `json:"usage,omitempty"`
263 | SystemFingerprint string `json:"system_fingerprint"`
264 |
265 | // http.Header
266 | }
267 |
268 | type TopLogProbs struct {
269 | Token string `json:"token"`
270 | LogProb float64 `json:"logprob"`
271 | Bytes []byte `json:"bytes,omitempty"`
272 | }
273 |
274 | // LogProb represents the probability information for a token.
275 | type LogProb struct {
276 | Token string `json:"token"`
277 | LogProb float64 `json:"logprob"`
278 | Bytes []byte `json:"bytes,omitempty"` // Omitting the field if it is null
279 | // TopLogProbs is a list of the most likely tokens and their log probability, at this token position.
280 | // In rare cases, there may be fewer than the number of requested top_logprobs returned.
281 | TopLogProbs []TopLogProbs `json:"top_logprobs"`
282 | }
283 |
284 | // LogProbs is the top-level structure containing the log probability information.
285 | type LogProbs struct {
286 | // Content is a list of message content tokens with log probability information.
287 | Content []LogProb `json:"content"`
288 | }
289 |
290 | type FinishReason string
291 |
292 | const (
293 | FinishReasonStop FinishReason = "stop"
294 | FinishReasonLength FinishReason = "length"
295 | FinishReasonFunctionCall FinishReason = "function_call"
296 | FinishReasonToolCalls FinishReason = "tool_calls"
297 | FinishReasonContentFilter FinishReason = "content_filter"
298 | FinishReasonNull FinishReason = "null"
299 | )
300 |
301 | func (r FinishReason) MarshalJSON() ([]byte, error) {
302 | if r == FinishReasonNull || r == "" {
303 | return []byte("null"), nil
304 | }
305 | return []byte(`"` + string(r) + `"`), nil // best effort to not break future API changes
306 | }
307 |
308 | type ChatCompletionImageType string
309 |
310 | const (
311 | StreamImageTypeImageURL ChatCompletionImageType = "image_url"
312 | )
313 |
314 | type ChatCompletionImageURL struct {
315 | URL string `json:"url"`
316 | }
317 |
318 | // Image generation: https://openrouter.ai/docs/features/multimodal/image-generation
319 | type ChatCompletionImage struct {
320 | Index int `json:"index"`
321 | Type ChatCompletionImageType `json:"type"`
322 | ImageURL ChatCompletionImageURL `json:"image_url"`
323 | }
324 |
325 | type ChatCompletionReasoningDetailsType string
326 |
327 | const (
328 | ReasoningDetailsTypeText ChatCompletionReasoningDetailsType = "reasoning.text"
329 | ReasoningDetailsTypeSummary ChatCompletionReasoningDetailsType = "reasoning.summary"
330 | ReasoningDetailsTypeEncrypted ChatCompletionReasoningDetailsType = "reasoning.encrypted"
331 | )
332 |
333 | type ChatCompletionReasoningDetails struct {
334 | ID string `json:"id,omitempty"`
335 | Index int `json:"index"`
336 | Type ChatCompletionReasoningDetailsType `json:"type"`
337 | Text string `json:"text,omitempty"`
338 | Summary string `json:"summary,omitempty"`
339 | Data string `json:"data,omitempty"`
340 | Format string `json:"format,omitempty"`
341 | }
342 |
343 | type ChatCompletionChoice struct {
344 | Index int `json:"index"`
345 | Message ChatCompletionMessage `json:"message"`
346 | Reasoning *string `json:"reasoning,omitempty"`
347 | ReasoningDetails []ChatCompletionReasoningDetails `json:"reasoning_details,omitempty"`
348 | // FinishReason
349 | // stop: API returned complete message,
350 | // or a message terminated by one of the stop sequences provided via the stop parameter
351 | // length: Incomplete model output due to max_tokens parameter or token limit
352 | // function_call: The model decided to call a function
353 | // content_filter: Omitted content due to a flag from our content filters
354 | // null: API response still in progress or incomplete
355 | FinishReason FinishReason `json:"finish_reason"`
356 | LogProbs *LogProbs `json:"logprobs,omitempty"`
357 | }
358 |
359 | type PromptAnnotation struct {
360 | PromptIndex int `json:"prompt_index,omitempty"`
361 | ContentFilterResults ContentFilterResults `json:"content_filter_results,omitempty"`
362 | }
363 | type ContentFilterResults struct {
364 | Hate Hate `json:"hate,omitempty"`
365 | SelfHarm SelfHarm `json:"self_harm,omitempty"`
366 | Sexual Sexual `json:"sexual,omitempty"`
367 | Violence Violence `json:"violence,omitempty"`
368 | JailBreak JailBreak `json:"jailbreak,omitempty"`
369 | Profanity Profanity `json:"profanity,omitempty"`
370 | }
371 | type PromptFilterResult struct {
372 | Index int `json:"index"`
373 | ContentFilterResults ContentFilterResults `json:"content_filter_results,omitempty"`
374 | }
375 | type Hate struct {
376 | Filtered bool `json:"filtered"`
377 | Severity string `json:"severity,omitempty"`
378 | }
379 | type SelfHarm struct {
380 | Filtered bool `json:"filtered"`
381 | Severity string `json:"severity,omitempty"`
382 | }
383 | type Sexual struct {
384 | Filtered bool `json:"filtered"`
385 | Severity string `json:"severity,omitempty"`
386 | }
387 | type Violence struct {
388 | Filtered bool `json:"filtered"`
389 | Severity string `json:"severity,omitempty"`
390 | }
391 |
392 | type JailBreak struct {
393 | Filtered bool `json:"filtered"`
394 | Detected bool `json:"detected"`
395 | }
396 | type Profanity struct {
397 | Filtered bool `json:"filtered"`
398 | Detected bool `json:"detected"`
399 | }
400 |
401 | type StreamOptions struct {
402 | // If set, an additional chunk will be streamed before the data: [DONE] message.
403 | // The usage field on this chunk shows the token usage statistics for the entire request,
404 | // and the choices field will always be an empty array.
405 | // All other chunks will also include a usage field, but with a null value.
406 | IncludeUsage bool `json:"include_usage,omitempty"`
407 | }
408 |
409 | type ChatCompletionResponseFormatType string
410 |
411 | const (
412 | ChatCompletionResponseFormatTypeJSONObject ChatCompletionResponseFormatType = "json_object"
413 | ChatCompletionResponseFormatTypeJSONSchema ChatCompletionResponseFormatType = "json_schema"
414 | ChatCompletionResponseFormatTypeText ChatCompletionResponseFormatType = "text"
415 | )
416 |
417 | type ChatCompletionResponseFormat struct {
418 | Type ChatCompletionResponseFormatType `json:"type,omitempty"`
419 | JSONSchema *ChatCompletionResponseFormatJSONSchema `json:"json_schema,omitempty"`
420 | }
421 |
422 | type ChatCompletionResponseFormatJSONSchema struct {
423 | Name string `json:"name"`
424 | Description string `json:"description,omitempty"`
425 | Schema json.Marshaler `json:"schema"`
426 | Strict bool `json:"strict"`
427 | }
428 |
429 | type FunctionDefinition struct {
430 | Name string `json:"name"`
431 | Description string `json:"description,omitempty"`
432 | Strict bool `json:"strict,omitempty"`
433 | // Parameters is an object describing the function.
434 | // You can pass json.RawMessage to describe the schema,
435 | // or you can pass in a struct which serializes to the proper JSON schema.
436 | // The jsonschema package is provided for convenience, but you should
437 | // consider another specialized library if you require more complex schemas.
438 | Parameters any `json:"parameters"`
439 | }
440 |
441 | type ChatMessagePartType string
442 |
443 | const (
444 | ChatMessagePartTypeText ChatMessagePartType = "text"
445 | ChatMessagePartTypeImageURL ChatMessagePartType = "image_url"
446 | ChatMessagePartTypeFile ChatMessagePartType = "file"
447 | ChatMessagePartTypeInputAudio ChatMessagePartType = "input_audio"
448 | )
449 |
450 | type ChatMessagePart struct {
451 | Type ChatMessagePartType `json:"type,omitempty"`
452 | Text string `json:"text,omitempty"`
453 | // Prompt caching
454 | // https://openrouter.ai/docs/features/prompt-caching
455 | CacheControl *CacheControl `json:"cache_control,omitempty"`
456 |
457 | ImageURL *ChatMessageImageURL `json:"image_url,omitempty"`
458 | InputAudio *ChatMessageInputAudio `json:"input_audio,omitempty"`
459 | File *FileContent `json:"file,omitempty"`
460 | }
461 |
462 | type ImageURLDetail string
463 |
464 | const (
465 | ImageURLDetailHigh ImageURLDetail = "high"
466 | ImageURLDetailLow ImageURLDetail = "low"
467 | ImageURLDetailAuto ImageURLDetail = "auto"
468 | )
469 |
470 | type ChatMessageImageURL struct {
471 | URL string `json:"url,omitempty"`
472 | Detail ImageURLDetail `json:"detail,omitempty"`
473 | }
474 |
475 | type AudioFormat string
476 |
477 | const (
478 | AudioFormatMp3 AudioFormat = AudioFormat("mp3")
479 | AudioFormatWav AudioFormat = AudioFormat("wav")
480 | )
481 |
482 | type ChatMessageInputAudio struct {
483 | Data string `json:"data,omitempty"`
484 | Format AudioFormat `json:"format,omitempty"`
485 | }
486 |
487 | // FileContent represents file content for PDF processing
488 | type FileContent struct {
489 | Filename string `json:"filename"`
490 | FileData string `json:"file_data"`
491 | }
492 |
493 | // Content handles both string and multi-part content.
494 | type Content struct {
495 | Text string
496 | Multi []ChatMessagePart
497 | }
498 |
499 | type Annotation struct {
500 | Type AnnotationType `json:"type"`
501 | URLCitation URLCitation `json:"url_citation"`
502 | }
503 |
504 | type AnnotationType string
505 |
506 | const (
507 | AnnotationTypeUrlCitation AnnotationType = "url_citation"
508 | )
509 |
510 | type URLCitation struct {
511 | StartIndex int `json:"start_index"`
512 | EndIndex int `json:"end_index"`
513 | Title string `json:"title"`
514 | Content string `json:"content"`
515 | URL string `json:"url"`
516 | }
517 |
518 | type CacheControl struct {
519 | // Type only supports "ephemeral" for now.
520 | Type string `json:"type"`
521 | // TTL in format of "5m" | "1h"
522 | TTL *string `json:"ttl,omitempty"`
523 | }
524 |
525 | type ChatCompletionMessage struct {
526 | Role string `json:"role"`
527 | Content Content `json:"content,omitzero"`
528 | Refusal string `json:"refusal,omitempty"`
529 |
530 | // This property is used for the "reasoning" feature supported by deepseek-reasoner
531 | // - https://api-docs.deepseek.com/api/create-chat-completion#responses
532 | ReasoningContent *string `json:"reasoning_content,omitempty"`
533 |
534 | // Reasoning Used by all the other models
535 | Reasoning *string `json:"reasoning,omitempty"`
536 | // Required to preserve reasoning blocks https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
537 | ReasoningDetails []ChatCompletionReasoningDetails `json:"reasoning_details,omitempty"`
538 |
539 | FunctionCall *FunctionCall `json:"function_call,omitempty"`
540 |
541 | // For Role=assistant prompts this may be set to the tool calls generated by the model, such as function calls.
542 | ToolCalls []ToolCall `json:"tool_calls,omitempty"`
543 |
544 | // For Role=tool prompts this should be set to the ID given in the assistant's prior request to call a tool.
545 | ToolCallID string `json:"tool_call_id,omitempty"`
546 |
547 | // Web Search Annotations
548 | Annotations []Annotation `json:"annotations,omitempty"`
549 |
550 | // Multi-modal image generation (only in responses)
551 | Images []ChatCompletionImage `json:"images,omitempty"`
552 | }
553 |
554 | // MarshalJSON serializes ContentType as a string or array.
555 | func (c Content) MarshalJSON() ([]byte, error) {
556 | if c.Text != "" && len(c.Multi) == 0 {
557 | return json.Marshal(c.Text)
558 | }
559 | if len(c.Multi) > 0 && c.Text == "" {
560 | return json.Marshal(c.Multi)
561 | }
562 | return json.Marshal(nil)
563 | }
564 |
565 | // UnmarshalJSON deserializes ContentType from a string or array.
566 | func (c *Content) UnmarshalJSON(data []byte) error {
567 | var s string
568 | if err := json.Unmarshal(data, &s); err == nil && s != "" {
569 | c.Text = s
570 | c.Multi = nil
571 | return nil
572 | }
573 |
574 | var parts []ChatMessagePart
575 | if err := json.Unmarshal(data, &parts); err == nil && len(parts) > 0 {
576 | c.Text = ""
577 | c.Multi = parts
578 | return nil
579 | }
580 |
581 | c.Text = ""
582 | c.Multi = nil
583 | return nil
584 | }
585 |
586 | type Tool struct {
587 | Type ToolType `json:"type"`
588 | Function *FunctionDefinition `json:"function,omitempty"`
589 | }
590 |
591 | type ToolType string
592 |
593 | const (
594 | ToolTypeFunction ToolType = "function"
595 | )
596 |
597 | type ToolCall struct {
598 | // Index is not nil only in chat completion chunk object
599 | Index *int `json:"index,omitempty"`
600 | ID string `json:"id,omitempty"`
601 | Type ToolType `json:"type"`
602 | Function FunctionCall `json:"function"`
603 | }
604 |
605 | type FunctionCall struct {
606 | Name string `json:"name,omitempty"`
607 | // call function with arguments in JSON format
608 | Arguments string `json:"arguments,omitempty"`
609 | }
610 |
611 | func isSupportingModel(suffix, model string) bool {
612 | return true
613 | }
614 |
615 | // CreateChatCompletion — API call to Create a completion for the chat message.
616 | func (c *Client) CreateChatCompletion(
617 | ctx context.Context,
618 | request ChatCompletionRequest,
619 | ) (response ChatCompletionResponse, err error) {
620 | if request.Stream {
621 | err = ErrChatCompletionStreamNotSupported
622 | return
623 | }
624 |
625 | if !isSupportingModel(chatCompletionsSuffix, request.Model) {
626 | err = ErrChatCompletionInvalidModel
627 | return
628 | }
629 |
630 | req, err := c.newRequest(
631 | ctx,
632 | http.MethodPost,
633 | c.fullURL(chatCompletionsSuffix),
634 | withBody(request),
635 | )
636 | if err != nil {
637 | return
638 | }
639 |
640 | err = c.sendRequest(req, &response)
641 | return
642 | }
643 |
644 | type ChatCompletionStream struct {
645 | stream <-chan ChatCompletionStreamResponse
646 | done chan struct{}
647 | response *http.Response
648 | }
649 |
650 | // CreateChatCompletionStream — API call to Create a completion for the chat message with streaming.
651 | func (c *Client) CreateChatCompletionStream(
652 | ctx context.Context,
653 | request ChatCompletionRequest,
654 | ) (*ChatCompletionStream, error) {
655 | if !request.Stream {
656 | request.Stream = true
657 | }
658 |
659 | if !isSupportingModel(chatCompletionsSuffix, request.Model) {
660 | return nil, ErrChatCompletionInvalidModel
661 | }
662 |
663 | req, err := c.newRequest(
664 | ctx,
665 | http.MethodPost,
666 | c.fullURL(chatCompletionsSuffix),
667 | withBody(request),
668 | )
669 | if err != nil {
670 | return nil, err
671 | }
672 |
673 | req.Header.Set("Accept", "text/event-stream")
674 | req.Header.Set("Cache-Control", "no-cache")
675 | req.Header.Set("Connection", "keep-alive")
676 | req.Header.Set("Content-Type", "application/json")
677 |
678 | resp, err := c.config.HTTPClient.Do(req)
679 | if err != nil {
680 | return nil, err
681 | }
682 | if isFailureStatusCode(resp) {
683 | return nil, c.handleErrorResp(resp)
684 | }
685 |
686 | if resp.StatusCode != http.StatusOK {
687 | resp.Body.Close()
688 | return nil, errors.New("unexpected status code: " + resp.Status)
689 | }
690 |
691 | stream := make(chan ChatCompletionStreamResponse)
692 | done := make(chan struct{})
693 |
694 | go func() {
695 | defer close(stream)
696 | defer resp.Body.Close()
697 |
698 | reader := bufio.NewReader(resp.Body)
699 | for {
700 | select {
701 | case <-done:
702 | return
703 | case <-ctx.Done():
704 | slog.Info("Stream stopped due to context cancellation")
705 | return
706 | default:
707 | line, err := reader.ReadBytes('\n')
708 | if err != nil {
709 | if err == io.EOF {
710 | return
711 | }
712 | slog.Error("failed to read chat completion stream", "error", err)
713 | return
714 | }
715 | // If stream ended with done, stop immediately
716 | if strings.HasSuffix(string(line), "[DONE]\n") {
717 | return
718 | }
719 | // Ignore openrouter comments, empty lines
720 | if strings.HasPrefix(string(line), ": OPENROUTER PROCESSING") || string(line) == "\n" {
721 | continue
722 | }
723 | // Trim everything before json object from line
724 | line = bytes.TrimPrefix(line, []byte("data:"))
725 | // Decode object into a ChatCompletionResponse
726 | var chunk ChatCompletionStreamResponse
727 | if err := json.Unmarshal(line, &chunk); err != nil {
728 | slog.Error("failed to decode chat completion stream", "error", err, "line", string(line))
729 | return
730 | }
731 | stream <- chunk
732 | }
733 | }
734 | }()
735 |
736 | return &ChatCompletionStream{
737 | stream: stream,
738 | done: done,
739 | response: resp,
740 | }, nil
741 | }
742 |
743 | type ChatCompletionStreamChoiceDelta struct {
744 | Content string `json:"content,omitempty"`
745 | Role string `json:"role,omitempty"`
746 | FunctionCall *FunctionCall `json:"function_call,omitempty"`
747 | ToolCalls []ToolCall `json:"tool_calls,omitempty"`
748 | Refusal string `json:"refusal,omitempty"`
749 | Annotations []Annotation `json:"annotations,omitempty"`
750 | Images []ChatCompletionImage `json:"images,omitempty"`
751 | Reasoning *string `json:"reasoning,omitempty"`
752 | ReasoningDetails []ChatCompletionReasoningDetails `json:"reasoning_details,omitempty"`
753 |
754 | // This property is used for the "reasoning" feature supported by deepseek-reasoner
755 | // which is not in the official documentation.
756 | // the doc from deepseek:
757 | // - https://api-docs.deepseek.com/api/create-chat-completion#responses
758 | ReasoningContent string `json:"reasoning_content,omitempty"`
759 | }
760 | type ChatCompletionStreamChoiceLogprobs struct {
761 | Content []ChatCompletionTokenLogprob `json:"content,omitempty"`
762 | Refusal []ChatCompletionTokenLogprob `json:"refusal,omitempty"`
763 | }
764 | type ChatCompletionTokenLogprob struct {
765 | Token string `json:"token"`
766 | Bytes []int64 `json:"bytes,omitempty"`
767 | Logprob float64 `json:"logprob,omitempty"`
768 | TopLogprobs []ChatCompletionTokenLogprobTopLogprob `json:"top_logprobs"`
769 | }
770 | type ChatCompletionTokenLogprobTopLogprob struct {
771 | Token string `json:"token"`
772 | Bytes []int64 `json:"bytes"`
773 | Logprob float64 `json:"logprob"`
774 | }
775 | type ChatCompletionStreamChoice struct {
776 | Index int `json:"index"`
777 | Delta ChatCompletionStreamChoiceDelta `json:"delta"`
778 | Logprobs *ChatCompletionStreamChoiceLogprobs `json:"logprobs,omitempty"`
779 | FinishReason FinishReason `json:"finish_reason"`
780 | ContentFilterResults *ContentFilterResults `json:"content_filter_results,omitempty"`
781 | }
782 |
783 | type ChatCompletionStreamResponse struct {
784 | ID string `json:"id"`
785 | Object string `json:"object"`
786 | Created int64 `json:"created"`
787 | Model string `json:"model"`
788 | Choices []ChatCompletionStreamChoice `json:"choices"`
789 | SystemFingerprint string `json:"system_fingerprint"`
790 | PromptAnnotations []PromptAnnotation `json:"prompt_annotations,omitempty"`
791 | PromptFilterResults []PromptFilterResult `json:"prompt_filter_results,omitempty"`
792 | // An optional field that will only be present when you set stream_options: {"include_usage": true} in your request.
793 | // When present, it contains a null value except for the last chunk which contains the token usage statistics
794 | // for the entire request.
795 | Usage *Usage `json:"usage,omitempty"`
796 | }
797 |
798 | // Recv reads the next chunk from the stream.
799 | func (s *ChatCompletionStream) Recv() (ChatCompletionStreamResponse, error) {
800 | select {
801 | case chunk, ok := <-s.stream:
802 | if !ok {
803 | return ChatCompletionStreamResponse{}, io.EOF
804 | }
805 | return chunk, nil
806 | case <-s.done:
807 | return ChatCompletionStreamResponse{}, io.EOF
808 | }
809 | }
810 |
811 | // Close terminates the stream and cleans up resources.
812 | func (s *ChatCompletionStream) Close() {
813 | close(s.done)
814 | if s.response != nil {
815 | s.response.Body.Close()
816 | }
817 | }
818 |
819 | // String is a helper function returns a pointer to the string value passed in.
820 | func String(s string) *string {
821 | return &s
822 | }
823 |
824 | // DisableLogs disables the internally used logger.
825 | func DisableLogs() {
826 | discardHandler := slog.NewTextHandler(io.Discard, nil)
827 | logger := slog.New(discardHandler)
828 | slog.SetDefault(logger)
829 | }
830 |
--------------------------------------------------------------------------------