Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [ubuntu-latest, macos-15, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,14 @@ setfree config # interactive: view and edit base URL / API key
setfree config show
setfree config set --base-url https://gw.example.com --api-key sk-...
setfree config reset
setfree usage
setfree usage --lifetime
```

For the MindsHub gateway, `usage` shows token counts and billed cost grouped
by model. Add `--json` for machine-readable output. Usage checks are not
available for other gateways because their accounting APIs differ.

Environment variables override saved config for a single run, handy in scripts and CI:

| Variable | Overrides |
Expand Down
9 changes: 6 additions & 3 deletions internal/adapters/vscode/chatmodels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -95,9 +96,11 @@ func TestUpsertChatModels_FreshFile(t *testing.T) {
t.Errorf("groups = %+v", groups)
}

info, _ := os.Stat(path)
if info.Mode().Perm() != 0o600 {
t.Errorf("mode = %v, want 0600 — the file holds the API key in plain text", info.Mode().Perm())
if runtime.GOOS != "windows" {
info, _ := os.Stat(path)
if info.Mode().Perm() != 0o600 {
t.Errorf("mode = %v, want 0600 — the file holds the API key in plain text", info.Mode().Perm())
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions internal/adapters/vscode/vscode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -115,6 +116,9 @@ func TestPrepare_WritesChatModelsFromGatewayCatalog(t *testing.T) {

home := t.TempDir()
t.Setenv("HOME", home)
if runtime.GOOS == "windows" {
t.Setenv("APPDATA", home)
}

note, err := adapter{}.Prepare(context.Background(), gateway.Resolved{
Gateway: gateway.Gateway{BaseURL: srv.URL, APIKey: "mdb_k"},
Expand Down
3 changes: 3 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ func Run(args []string) int {
case "config":
maybeSelfUpdate()
return cmdConfig(args[1:])
case "usage":
maybeSelfUpdate()
return cmdUsage(args[1:])
default:
maybeSelfUpdate()
return cmdLaunch(args[0], args[1:])
Expand Down
89 changes: 89 additions & 0 deletions internal/app/usagecmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package app

import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
"time"

"github.com/mindsdb/setfree/internal/config"
"github.com/mindsdb/setfree/internal/usage"
)

func cmdUsage(args []string) int {
rangeValue := "period"
jsonOutput := false
for _, arg := range args {
switch arg {
case "--lifetime":
rangeValue = "lifetime"
case "--json":
jsonOutput = true
default:
return fail(fmt.Errorf("unknown flag %q\n\nUsage: setfree usage [--lifetime] [--json]", arg))
}
}

e, err := newEnv()
if err != nil {
return fail(err)
}
resolved, err := e.resolver().Resolve("codex")
if err != nil {
return fail(err)
}
if !isMindsHubGateway(resolved.Gateway.BaseURL) {
return fail(fmt.Errorf("usage checks are currently supported only for the MindsHub gateway"))
}
if resolved.Gateway.APIKey == "" {
return fail(fmt.Errorf("no API key is configured for the selected gateway"))
}

ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
summary, err := usage.Fetch(ctx, config.MindsHubUsageAPI(), resolved.Gateway.APIKey, rangeValue)
cancel()
if err != nil {
return fail(err)
}
if jsonOutput {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(summary); err != nil {
return fail(fmt.Errorf("encoding usage output: %w", err))
}
return 0
}

fmt.Printf("MindsHub usage (%s)\n", summary.Range.Requested)
for _, row := range summary.Results {
name := row.Dimensions.Label
if name == "" {
name = row.Dimensions.Alias
}
cost := "unavailable"
if row.Cost != nil {
cost = "$" + row.Cost.TotalUSD
}
fmt.Printf(" %-24s %d input / %d output tokens %s\n", name, row.Usage.InputTokens, row.Usage.OutputTokens, cost)
}
return 0
}

func isMindsHubGateway(raw string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
provider, ok := config.FindProvider("mindshub")
if !ok {
return false
}
providerURL, err := url.Parse(provider.BaseURL)
if err != nil {
return false
}
return strings.EqualFold(parsed.Scheme, providerURL.Scheme) && strings.EqualFold(parsed.Host, providerURL.Host)
}
6 changes: 6 additions & 0 deletions internal/config/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ func MindsHubAuthAPI() string {
return fmt.Sprintf("https://auth.%s/v1", mindsHubDomain())
}

// MindsHubUsageAPI returns the per-model usage endpoint for the MindsHub
// account associated with the configured API key.
func MindsHubUsageAPI() string {
return MindsHubAuthAPI() + "/usage/summary/"
}

// MindsHubConsoleURL returns the web console's address.
func MindsHubConsoleURL() string {
return fmt.Sprintf("https://console.%s", mindsHubDomain())
Expand Down
7 changes: 7 additions & 0 deletions internal/config/providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ func TestMindsHubDefaultsToProduction(t *testing.T) {
}
}

func TestMindsHubUsageAPI_FollowsDomainOverride(t *testing.T) {
t.Setenv(EnvMindsHubDomain, "staging.mindshub.ai")
if got := MindsHubUsageAPI(); got != "https://auth.staging.mindshub.ai/v1/usage/summary/" {
t.Errorf("usage API = %q", got)
}
}

func TestMindsHubConsoleURL_FollowsDomainOverride(t *testing.T) {
t.Setenv(EnvMindsHubDomain, "staging.mindshub.ai")
if got := MindsHubConsoleURL(); got != "https://console.staging.mindshub.ai" {
Expand Down
1 change: 1 addition & 0 deletions internal/ui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const HelpText = `SetFree — Any coding CLI. Any gateway. Any model.
Usage:
setfree <cli> [args...]
setfree config [show|set|reset]
setfree usage [--lifetime] [--json]
setfree version

Examples:
Expand Down
14 changes: 8 additions & 6 deletions internal/update/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,14 @@ func TestReplaceSelf_SwapsContentAndSetsExecutablePermission(t *testing.T) {
if err != nil || string(got) != "new content" {
t.Fatalf("ReadFile = %q, %v", got, err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if info.Mode().Perm()&0o100 == 0 {
t.Error("expected the replaced binary to be executable")
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if info.Mode().Perm()&0o100 == 0 {
t.Error("expected the replaced binary to be executable")
}
}

// No leftover temp files.
Expand Down
75 changes: 75 additions & 0 deletions internal/usage/usage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Package usage reads the authenticated gateway's usage summary.
package usage

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)

type Summary struct {
Range SummaryRange `json:"range"`
Results []ModelUsage `json:"results"`
Totals Totals `json:"totals"`
}

type SummaryRange struct {
Requested string `json:"requested"`
}

type ModelUsage struct {
Dimensions ModelDimensions `json:"dimensions"`
Usage TokenUsage `json:"usage"`
Cost *Cost `json:"cost"`
}

type ModelDimensions struct {
Alias string `json:"model_alias"`
Label string `json:"model_label"`
}

type TokenUsage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
}

type Cost struct {
TotalUSD string `json:"total_usd"`
}

type Totals struct {
Usage TokenUsage `json:"usage"`
Cost *Cost `json:"cost"`
}

// Fetch retrieves a usage summary without exposing the API key in output or errors.
func Fetch(ctx context.Context, endpoint, apiKey, rangeValue string) (Summary, error) {
separator := "?"
if strings.Contains(endpoint, "?") {
separator = "&"
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet,
endpoint+separator+"range="+rangeValue+"&group_by=model", nil)
if err != nil {
return Summary{}, fmt.Errorf("creating usage request: %w", err)
}
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Accept", "application/json")

response, err := http.DefaultClient.Do(request)
if err != nil {
return Summary{}, fmt.Errorf("requesting usage: %w", err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return Summary{}, fmt.Errorf("usage endpoint returned %s", response.Status)
}

var summary Summary
if err := json.NewDecoder(response.Body).Decode(&summary); err != nil {
return Summary{}, fmt.Errorf("decoding usage response: %w", err)
}
return summary, nil
}
45 changes: 45 additions & 0 deletions internal/usage/usage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package usage

import (
"context"
"net/http"
"net/http/httptest"
"testing"
)

func TestFetchRequestsModelGroupedUsage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("range") != "lifetime" || r.URL.Query().Get("group_by") != "model" {
t.Fatalf("query = %q, want lifetime/model", r.URL.RawQuery)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Fatalf("authorization = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"range":{"requested":"lifetime"},"results":[{"dimensions":{"model_alias":"air","model_label":"MindsHub Air"},"usage":{"input_tokens":10,"output_tokens":4},"cost":{"total_usd":"0.12"}}],"totals":{"usage":{"input_tokens":10,"output_tokens":4},"cost":{"total_usd":"0.12"}}}`))
}))
defer server.Close()

summary, err := Fetch(context.Background(), server.URL, "test-key", "lifetime")
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if len(summary.Results) != 1 || summary.Results[0].Dimensions.Alias != "air" {
t.Fatalf("results = %+v", summary.Results)
}
if summary.Results[0].Cost == nil || summary.Results[0].Cost.TotalUSD != "0.12" {
t.Fatalf("cost = %+v", summary.Results[0].Cost)
}
}

func TestFetchDoesNotExposeAPIKeyOnHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer server.Close()

_, err := Fetch(context.Background(), server.URL, "secret-key", "period")
if err == nil || err.Error() != "usage endpoint returned 401 Unauthorized" {
t.Fatalf("error = %v", err)
}
}
Loading