├── .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 | [![Go Reference](https://pkg.go.dev/badge/github.com/revrost/go-openrouter.svg)](https://pkg.go.dev/github.com/revrost/go-openrouter) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/revrost/go-openrouter)](https://goreportcard.com/report/github.com/revrost/go-openrouter) 5 | [![codecov](https://codecov.io/gh/revrost/go-openrouter/branch/master/graph/badge.svg?token=bCbIfHLIsW)](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 | --------------------------------------------------------------------------------