Skip to content
Open
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
138 changes: 0 additions & 138 deletions .github/workflows/binary-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -251,141 +251,3 @@ jobs:
dist/*.zip
dist/checksums.txt

update-homebrew-tap:
runs-on: ubuntu-24.04
needs: publish-release
steps:
- name: Download binary package artifacts
uses: actions/download-artifact@v6
with:
path: dist
merge-multiple: true

- name: Generate Homebrew formula values
id: formula
shell: bash
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
version="${tag#v}"

cd dist
sha256sum *.tar.gz *.zip > checksums.txt

arm_file="cpa-usage-keeper_${tag}_darwin_arm64.tar.gz"
amd_file="cpa-usage-keeper_${tag}_darwin_amd64.tar.gz"
arm_sha="$(awk -v f="${arm_file}" '$2 == f {print $1}' checksums.txt)"
amd_sha="$(awk -v f="${amd_file}" '$2 == f {print $1}' checksums.txt)"

test -n "${arm_sha}"
test -n "${amd_sha}"

{
echo "tag=${tag}"
echo "version=${version}"
echo "arm_file=${arm_file}"
echo "amd_file=${amd_file}"
echo "arm_sha=${arm_sha}"
echo "amd_sha=${amd_sha}"
} >> "${GITHUB_OUTPUT}"

- name: Checkout Homebrew tap
uses: actions/checkout@v6
with:
repository: Willxup/homebrew-cpa-usage-keeper
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: homebrew-tap

- name: Update formula
shell: bash
env:
TAG: ${{ steps.formula.outputs.tag }}
VERSION: ${{ steps.formula.outputs.version }}
ARM_FILE: ${{ steps.formula.outputs.arm_file }}
AMD_FILE: ${{ steps.formula.outputs.amd_file }}
ARM_SHA: ${{ steps.formula.outputs.arm_sha }}
AMD_SHA: ${{ steps.formula.outputs.amd_sha }}
run: |
set -euo pipefail
mkdir -p homebrew-tap/Formula

cat > homebrew-tap/Formula/cpa-usage-keeper.rb <<EOF
class CpaUsageKeeper < Formula
desc "Standalone CPA usage persistence and dashboard service"
homepage "https://github.com/Willxup/cpa-usage-keeper"
license "MIT"
version "${VERSION}"

depends_on :macos

on_macos do
on_arm do
url "https://github.com/Willxup/cpa-usage-keeper/releases/download/${TAG}/${ARM_FILE}"
sha256 "${ARM_SHA}"
end

on_intel do
url "https://github.com/Willxup/cpa-usage-keeper/releases/download/${TAG}/${AMD_FILE}"
sha256 "${AMD_SHA}"
end
end

def install
inreplace ".env.example", "WORK_DIR=./data", "WORK_DIR=#{var}/cpa-usage-keeper"

bin.install "cpa-usage-keeper"
etc.install ".env.example" => "cpa-usage-keeper.env.example"
pkgshare.install "README.md", "README.zh.md", "LICENSE"
end

def post_install
(var/"cpa-usage-keeper").mkpath

env_file = etc/"cpa-usage-keeper.env"
cp etc/"cpa-usage-keeper.env.example", env_file unless env_file.exist?
end

service do
run [opt_bin/"cpa-usage-keeper", "--env", etc/"cpa-usage-keeper.env"]
working_dir var/"cpa-usage-keeper"
keep_alive true
log_path var/"log/cpa-usage-keeper.log"
error_log_path var/"log/cpa-usage-keeper.err.log"
end

def caveats
<<~EOS
Edit the configuration before starting the service:

#{etc}/cpa-usage-keeper.env

Required values include CPA_BASE_URL and CPA_MANAGEMENT_KEY.

Start the service with:

brew services start cpa-usage-keeper
EOS
end

test do
assert_match "Usage of", shell_output("#{bin}/cpa-usage-keeper --help 2>&1")
end
end
EOF

- name: Commit and push tap update
shell: bash
working-directory: homebrew-tap
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

git add Formula/cpa-usage-keeper.rb
if git diff --cached --quiet; then
echo "Homebrew formula already up to date"
exit 0
fi

git commit -m "Update cpa-usage-keeper to ${GITHUB_REF_NAME}"
git push
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ jobs:
path-type: inherit
install: mingw-w64-ucrt-x86_64-gcc

- name: Create dummy dist for go:embed
run: mkdir -p web/dist && touch web/dist/.gitkeep

- name: Run backend tests
if: ${{ runner.os != 'Windows' }}
run: go test ./cmd/... ./internal/...
Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# syntax=docker/dockerfile:1

FROM node:22-alpine AS web-builder
# web-builder: always build on native platform to avoid QEMU issues with npm/node
FROM --platform=$BUILDPLATFORM node:22-alpine AS web-builder
WORKDIR /app/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
Expand Down
46 changes: 39 additions & 7 deletions internal/api/usage_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ type usageSourceFilterOption struct {
DisplayName string `json:"displayName"`
}

type modelFilterOption struct {
Value string `json:"value"`
Label string `json:"label"`
}

type usageEventFilterOptionsResponse struct {
Models []string `json:"models"`
Sources []usageSourceFilterOption `json:"sources"`
Expand All @@ -39,6 +44,7 @@ type usageEventPayload struct {
Timestamp string `json:"timestamp"`
APIKey string `json:"api_key,omitempty"`
Model string `json:"model"`
ModelAlias *string `json:"model_alias,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
ExecutorType string `json:"executor_type,omitempty"`
Expand All @@ -52,6 +58,7 @@ type usageEventPayload struct {
LatencyMS int64 `json:"latency_ms"`
TTFTMS *int64 `json:"ttft_ms,omitempty"`
SpeedTPS *float64 `json:"speed_tps,omitempty"`
SpeedTotalTPS *float64 `json:"speed_total_tps,omitempty"`
Tokens usageEventTokenPayload `json:"tokens"`
CostUSD float64 `json:"cost_usd"`
CostAvailable bool `json:"cost_available"`
Expand All @@ -75,12 +82,20 @@ func registerUsageEventsRoute(
cpaAPIKeyProvider service.CPAAPIKeyProvider,
) {
router.GET("/usage/events/filters/models", func(c *gin.Context) {
models, err := loadUsageEventModelFilterOptions(c, usageProvider)
models, labels, err := loadUsageEventModelFilterOptions(c, usageProvider)
if err != nil {
writeInternalError(c, "list usage event model filter options failed", err)
return
}
c.JSON(http.StatusOK, gin.H{"models": models})
options := make([]modelFilterOption, 0, len(models))
for _, model := range models {
label := model
if alias, ok := labels[model]; ok {
label = alias
}
options = append(options, modelFilterOption{Value: model, Label: label})
}
c.JSON(http.StatusOK, gin.H{"models": options})
})

router.GET("/usage/events/filters/sources", func(c *gin.Context) {
Expand Down Expand Up @@ -165,6 +180,7 @@ func buildUsageEventsPayload(rows []servicedto.UsageEventRecord, resolver usageI
ID: id,
Timestamp: timeutil.FormatStorageTime(row.Timestamp),
APIKey: usageEventAPIKeyLabel(row.APIGroupKey, apiKeyInfos),
ModelAlias: row.ModelAlias,
Model: row.Model,
ReasoningEffort: strings.TrimSpace(row.ReasoningEffort),
ServiceTier: strings.TrimSpace(row.ServiceTier),
Expand All @@ -178,6 +194,7 @@ func buildUsageEventsPayload(rows []servicedto.UsageEventRecord, resolver usageI
LatencyMS: row.LatencyMS,
TTFTMS: row.TTFTMS,
SpeedTPS: usageEventSpeedTPS(row),
SpeedTotalTPS: usageEventSpeedTotalTPS(row),
CostUSD: row.CostUSD,
CostAvailable: row.CostAvailable,
PricingStyle: strings.TrimSpace(row.PricingStyle),
Expand All @@ -200,14 +217,29 @@ func usageEventSpeedTPS(row servicedto.UsageEventRecord) *float64 {
if visibleOutputTokens < 0 {
visibleOutputTokens = 0
}
if row.TTFTMS == nil || *row.TTFTMS <= 0 || row.LatencyMS <= *row.TTFTMS || visibleOutputTokens <= 1 {
// 生成时间过短(<10ms)时,毫秒级精度无法支撑可信的 TPS 计算。
if row.TTFTMS == nil || *row.TTFTMS <= 0 || row.LatencyMS-*row.TTFTMS < 10 || visibleOutputTokens <= 1 {
return nil
}
// Speed 只衡量首字后可见输出 token 的平均生成速度,避免把等待首字的时间重复计入。
speed := float64(visibleOutputTokens-1) / (float64(row.LatencyMS-*row.TTFTMS) / 1000)
return &speed
}

// usageEventSpeedTotalTPS 计算包含 TTFT 在内的总耗时 TPS,更贴近用户体感。
func usageEventSpeedTotalTPS(row servicedto.UsageEventRecord) *float64 {
visibleOutputTokens := row.OutputTokens - row.ReasoningTokens
if visibleOutputTokens < 0 {
visibleOutputTokens = 0
}
// 总耗时过短(<10ms)时,毫秒级精度无法支撑可信的 TPS 计算。
if row.LatencyMS < 10 || visibleOutputTokens <= 0 {
return nil
}
speed := float64(visibleOutputTokens) / (float64(row.LatencyMS) / 1000)
return &speed
}

func usageEventAPIKeyLabel(apiGroupKey string, apiKeyInfos map[string]analysisAPIKeyInfo) string {
apiKey := strings.TrimSpace(apiGroupKey)
if apiKey == "" {
Expand All @@ -231,15 +263,15 @@ func usageEventPublicSource(row servicedto.UsageEventRecord, identity resolvedUs
}
}

func loadUsageEventModelFilterOptions(c *gin.Context, usageProvider service.UsageProvider) ([]string, error) {
func loadUsageEventModelFilterOptions(c *gin.Context, usageProvider service.UsageProvider) ([]string, map[string]string, error) {
if usageProvider == nil {
return []string{}, nil
return []string{}, nil, nil
}
options, err := usageProvider.ListUsageEventFilterOptions(c.Request.Context(), servicedto.UsageFilter{})
if err != nil {
return nil, err
return nil, nil, err
}
return options.Models, nil
return options.Models, options.ModelLabels, nil
}

func loadUsageEventSourceFilterOptions(c *gin.Context, usageIdentityProvider service.UsageIdentityProvider) ([]usageSourceFilterOption, error) {
Expand Down
23 changes: 17 additions & 6 deletions internal/api/usage_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ func TestUsageEventsReturnsFilteredRows(t *testing.T) {
if !contains(body, `"speed_tps":29`) {
t.Fatalf("expected speed_tps in response body: %s", body)
}
if !contains(body, `"speed_total_tps":28.8`) {
t.Fatalf("expected speed_total_tps in response body: %s", body)
}
if !contains(body, `"executor_type":"responses"`) {
t.Fatalf("expected executor_type in response body: %s", body)
}
Expand Down Expand Up @@ -525,7 +528,7 @@ func TestUsageEventModelFilterOptionsReturnsStableModels(t *testing.T) {
t.Fatalf("expected model filters endpoint to ignore query filters, got %+v", provider.lastFilter)
}
body := resp.Body.String()
if body != `{"models":["claude-sonnet","gpt-5"]}` {
if body != `{"models":[{"value":"claude-sonnet","label":"claude-sonnet"},{"value":"gpt-5","label":"gpt-5"}]}` {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In DTOs intended for frontend consumption, maintain unique identifiers in the 'key' field and use a separate 'label' field strictly for display purposes. Overwriting unique keys with display labels can cause rendering issues in frameworks like React and prevent the client from uniquely identifying data sources.

Suggested change
if body != `{"models":[{"value":"claude-sonnet","label":"claude-sonnet"},{"value":"gpt-5","label":"gpt-5"}]}` {
if body != "{\"models\":[{\"key\":\"claude-sonnet\",\"label\":\"claude-sonnet\"},{\"key\":\"gpt-5\",\"label\":\"gpt-5\"}]}" {
References
  1. In DTOs intended for frontend consumption, maintain unique identifiers in the 'key' field and use a separate 'label' field strictly for display purposes. Overwriting unique keys with display labels can cause rendering issues in frameworks like React and prevent the client from uniquely identifying data sources.

t.Fatalf("expected stable model filter options, got %s", body)
}
}
Expand Down Expand Up @@ -579,13 +582,21 @@ func TestUsageEventSpeedTPS(t *testing.T) {
},
},
{
name: "omits speed when only first visible token is present",
name: "omits speed when generation duration is too short",
row: servicedto.UsageEventRecord{
LatencyMS: 2045,
TTFTMS: usageEventInt64Ptr(45),
OutputTokens: 4,
ReasoningTokens: 3,
LatencyMS: 54,
TTFTMS: usageEventInt64Ptr(45),
OutputTokens: 100,
},
},
{
name: "includes speed when generation duration reaches threshold",
row: servicedto.UsageEventRecord{
LatencyMS: 55,
TTFTMS: usageEventInt64Ptr(45),
OutputTokens: 100,
},
want: usageEventFloat64Ptr(9900),
},
}

Expand Down
4 changes: 3 additions & 1 deletion internal/repository/dto/usage_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ type UsageEventsPageRecord struct {

// UsageEventFilterOptionsRecord 是 usage events 筛选项的仓储查询结果。
type UsageEventFilterOptionsRecord struct {
Models []string
Models []string
ModelLabels map[string]string
}

// UsageEventRecord 是单条 usage event 的查询结果。
Expand All @@ -23,6 +24,7 @@ type UsageEventRecord struct {
Timestamp time.Time
APIGroupKey string
Model string
ModelAlias *string
ReasoningEffort string
ServiceTier string
ExecutorType string
Expand Down
4 changes: 2 additions & 2 deletions internal/repository/query_projection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ func TestRepositoryQueriesAvoidKnownFullEntityReads(t *testing.T) {
assertFileContains(t, "usage.go",
"Select(usageEventProjectionColumns).Order(\"timestamp DESC, id DESC\")",
"Select(usageOverviewRawEventProjectionColumns).\n\t\tOrder(\"timestamp asc\")",
"usageOverviewRawEventProjectionColumns = \"api_group_key, provider, auth_type, model, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens\"",
"usageOverviewRawEventProjectionColumns = \"api_group_key, provider, auth_type, model, model_alias, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens\"",
)
assertFileContains(t, "usage_recent_event_cache.go",
"Select(\"api_group_key, provider, auth_type, model, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens\")",
"Select(\"api_group_key, provider, auth_type, model, model_alias, timestamp, source, auth_index, failed, latency_ms, ttft_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, total_tokens\")",
)

assertFileDoesNotContain(t, "usage_identities.go",
Expand Down
Loading