Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for querying billing details #591

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions billing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package openai

import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
)

const billingUsageSuffix = "/billing/usage"

type CostLineItemResponse struct {
Name string `json:"name"`
Cost float64 `json:"cost"` // in cents
}

type DailyCostResponse struct {
TimestampRaw float64 `json:"timestamp"`
LineItems []CostLineItemResponse `json:"line_items"`

Time time.Time `json:"-"`
}

type BillingUsageResponse struct {
Object string `json:"object"`
DailyCosts []DailyCostResponse `json:"daily_costs"`
TotalUsage float64 `json:"total_usage"` // in cents

httpHeader
}

// @todo remove this and replace w/ time.DateOnly once github build
// environment is upgraded to a more recent go toolchain.
const DateOnly = "2006-01-02"

// currently the OpenAI usage API is not publicly documented and will explictly
// reject requests using an API key authorization. however, it can be utilized
// logging into https://platform.openai.com/usage and retrieving your session
// key from the browser console. session keys have the form 'sess-<keytext>'.
var (
BillingAPIKeyNotAllowedErrMsg = "Your request to GET /dashboard/billing/usage must be made with a session key (that is, it can only be made from the browser)." //nolint:lll
ErrSessKeyRequired = errors.New("an OpenAI API key cannot be used for this request; a session key is required instead") //nolint:lll
)

// GetBillingUsage — API call to Get billing usage details.
func (c *Client) GetBillingUsage(ctx context.Context, startDate time.Time,
endDate time.Time) (response BillingUsageResponse, err error) {
startDateArg := fmt.Sprintf("start_date=%v", startDate.Format(DateOnly))
endDateArg := fmt.Sprintf("end_date=%v", endDate.Format(DateOnly))
queryParams := fmt.Sprintf("%v&%v", startDateArg, endDateArg)
urlSuffix := fmt.Sprintf("%v?%v", billingUsageSuffix, queryParams)

req, err := c.newRequest(ctx, http.MethodGet, c.fullDashboardURL(urlSuffix))
if err != nil {
return

Check warning on line 57 in billing.go

View check run for this annotation

Codecov / codecov/patch

billing.go#L57

Added line #L57 was not covered by tests
}

err = c.sendRequest(req, &response)
if err != nil {
if strings.Contains(err.Error(), BillingAPIKeyNotAllowedErrMsg) {
err = ErrSessKeyRequired

Check warning on line 63 in billing.go

View check run for this annotation

Codecov / codecov/patch

billing.go#L63

Added line #L63 was not covered by tests
}
return
}

for idx, d := range response.DailyCosts {
dTime := time.Unix(int64(d.TimestampRaw), 0)
response.DailyCosts[idx].Time = dTime
}

return
}
116 changes: 116 additions & 0 deletions billing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package openai_test

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"

"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/internal/test/checks"
)

const (
TestTotCost = float64(126.234)
TestEndDate = "2023-11-30"
TestStartDate = "2023-11-01"
TestSessionKey = "sess-whatever"
TestAPIKey = "sk-whatever"
)

func TestBillingUsageAPIKey(t *testing.T) {
client, server, teardown := setupOpenAITestServerWithAuth(TestAPIKey)
defer teardown()
server.RegisterHandler("/dashboard/billing/usage", handleBillingEndpoint)

ctx := context.Background()

endDate, err := time.Parse(openai.DateOnly, TestEndDate)
checks.NoError(t, err)
startDate, err := time.Parse(openai.DateOnly, TestStartDate)
checks.NoError(t, err)

_, err = client.GetBillingUsage(ctx, startDate, endDate)
checks.HasError(t, err)
}

func TestBillingUsageSessKey(t *testing.T) {
client, server, teardown := setupOpenAITestServerWithAuth(TestSessionKey)
defer teardown()
server.RegisterHandler("/dashboard/billing/usage", handleBillingEndpoint)

ctx := context.Background()
endDate, err := time.Parse(openai.DateOnly, TestEndDate)
checks.NoError(t, err)
startDate, err := time.Parse(openai.DateOnly, TestStartDate)
checks.NoError(t, err)

resp, err := client.GetBillingUsage(ctx, startDate, endDate)
checks.NoError(t, err)

if resp.TotalUsage != TestTotCost {
t.Errorf("expected total cost %v but got %v", TestTotCost,
resp.TotalUsage)
}
for idx, dc := range resp.DailyCosts {
if dc.Time.Before(startDate) {
t.Errorf("expected daily cost%v date(%v) before start date %v", idx,
dc.Time, TestStartDate)
}
if dc.Time.After(endDate) {
t.Errorf("expected daily cost%v date(%v) after end date %v", idx,
dc.Time, TestEndDate)
}
}
}

// handleBillingEndpoint Handles the billing usage endpoint by the test server.
func handleBillingEndpoint(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if strings.Contains(r.Header.Get("Authorization"), TestAPIKey) {
http.Error(w, openai.BillingAPIKeyNotAllowedErrMsg, http.StatusUnauthorized)
return
}

var resBytes []byte

dailyCosts := make([]openai.DailyCostResponse, 0)

d, _ := time.Parse(openai.DateOnly, TestStartDate)
d = d.Add(24 * time.Hour)
dailyCosts = append(dailyCosts, openai.DailyCostResponse{
TimestampRaw: float64(d.Unix()),
LineItems: []openai.CostLineItemResponse{
{Name: "GPT-4 Turbo", Cost: 0.12},
{Name: "Audio models", Cost: 0.24},
},
Time: time.Time{},
})
d = d.Add(24 * time.Hour)
dailyCosts = append(dailyCosts, openai.DailyCostResponse{
TimestampRaw: float64(d.Unix()),
LineItems: []openai.CostLineItemResponse{
{Name: "image models", Cost: 0.56},
},
Time: time.Time{},
})
res := &openai.BillingUsageResponse{
Object: "list",
DailyCosts: dailyCosts,
TotalUsage: TestTotCost,
}

resBytes, err := json.Marshal(res)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

fmt.Fprintln(w, string(resBytes))
}
7 changes: 7 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,13 @@ func (c *Client) baseURLWithAzureDeployment(baseURL, suffix, model string) (newB
return baseURL
}

// fullDashboardURL returns full URL for a dashboard request.
func (c *Client) fullDashboardURL(suffix string, _ ...any) string {
// @todo this needs to be updated for c.config.APIType == APITypeAzure || c.config.APIType == APITypeAzureAD

return fmt.Sprintf("%s%s", c.config.DashboardBaseURL, suffix)
}

func (c *Client) handleErrorResp(resp *http.Response) error {
var errRes ErrorResponse
err := json.NewDecoder(resp.Body).Decode(&errRes)
Expand Down
3 changes: 3 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

const (
openaiAPIURLv1 = "https://api.openai.com/v1"
openaiAPIDashboardURL = "https://api.openai.com/dashboard"
defaultEmptyMessagesLimit uint = 300

azureAPIPrefix = "openai"
Expand All @@ -31,6 +32,7 @@ type ClientConfig struct {
authToken string

BaseURL string
DashboardBaseURL string
OrgID string
APIType APIType
APIVersion string // required when APIType is APITypeAzure or APITypeAzureAD
Expand All @@ -45,6 +47,7 @@ func DefaultConfig(authToken string) ClientConfig {
return ClientConfig{
authToken: authToken,
BaseURL: openaiAPIURLv1,
DashboardBaseURL: openaiAPIDashboardURL,
APIType: APITypeOpenAI,
AssistantVersion: defaultAssistantVersion,
OrgID: "",
Expand Down
10 changes: 7 additions & 3 deletions internal/test/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ func GetTestToken() string {

type ServerTest struct {
handlers map[string]handler
authKey string
}
type handler func(w http.ResponseWriter, r *http.Request)

func NewTestServer() *ServerTest {
return &ServerTest{handlers: make(map[string]handler)}
func NewTestServer(authKeyIn string) *ServerTest {
return &ServerTest{
handlers: make(map[string]handler),
authKey: authKeyIn,
}
}

func (ts *ServerTest) RegisterHandler(path string, handler handler) {
Expand All @@ -36,7 +40,7 @@ func (ts *ServerTest) OpenAITestServer() *httptest.Server {
log.Printf("received a %s request at path %q\n", r.Method, r.URL.Path)

// check auth
if r.Header.Get("Authorization") != "Bearer "+GetTestToken() && r.Header.Get("api-key") != GetTestToken() {
if r.Header.Get("Authorization") != "Bearer "+ts.authKey && r.Header.Get("api-key") != ts.authKey {
w.WriteHeader(http.StatusUnauthorized)
return
}
Expand Down
11 changes: 8 additions & 3 deletions openai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,23 @@ import (
)

func setupOpenAITestServer() (client *openai.Client, server *test.ServerTest, teardown func()) {
server = test.NewTestServer()
return setupOpenAITestServerWithAuth(test.GetTestToken())
}

func setupOpenAITestServerWithAuth(authKey string) (client *openai.Client, server *test.ServerTest, teardown func()) {
server = test.NewTestServer(authKey)
ts := server.OpenAITestServer()
ts.Start()
teardown = ts.Close
config := openai.DefaultConfig(test.GetTestToken())
config := openai.DefaultConfig(authKey)
config.BaseURL = ts.URL + "/v1"
config.DashboardBaseURL = ts.URL + "/dashboard"
client = openai.NewClientWithConfig(config)
return
}

func setupAzureTestServer() (client *openai.Client, server *test.ServerTest, teardown func()) {
server = test.NewTestServer()
server = test.NewTestServer(test.GetTestToken())
ts := server.OpenAITestServer()
ts.Start()
teardown = ts.Close
Expand Down
Loading