feat(usage): self-service usage and limit status endpoint#498
Conversation
Add GET /v1/usage so callers can read their own usage, budget, and rate limit status without admin access (#466). The response is scoped to the caller's effective user path: the path bound to the managed API key, or the X-GoModel-User-Path header for master-key callers. - usage summary over a date window (start_date/end_date/days, default last 30 days UTC), null when no storage backend records usage - status of every budget covering the path (spent/remaining/period/ exceeded), including budgets inherited from ancestor paths - live counters for every user-path rate limit rule covering the path; provider/model rules are excluded as shared infrastructure - polling the endpoint does not consume rate limit quota New non-enforcing service methods back the endpoint: budget.Service.StatusesForPath evaluates all matching budgets without stopping at the first exceeded one, and ratelimit.Service.StatusesForUserPath reports user-path rules by subtree. The server reaches them through optional type-assertion upgrades of the existing enforcement interfaces. Shared plumbing: the date-range parser moved from admin to usage.BuildDateRange, and the usage reader is created once in app wiring and shared by the server and admin handlers. Swagger regen also picks up the admin rate-limit endpoints (#482) that were never regenerated into the checked-in docs. Closes #466 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
📝 WalkthroughWalkthroughAdds a self-service ChangesUsage status feature
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant Handler as Handler.UsageStatus
participant Usage as UsageSummarizer
participant Budget as budget.Service
participant RateLimit as ratelimit.Service
Client->>Handler: GET /v1/usage
Handler->>Handler: resolve userPath and usage window
Handler->>Usage: GetSummary(params)
Usage-->>Handler: UsageSummary
Handler->>Budget: StatusesForPath(userPath, now)
Budget-->>Handler: []CheckResult
Handler->>RateLimit: StatusesForUserPath(userPath, now)
RateLimit-->>Handler: []Status
Handler-->>Client: 200 usageStatusResponse
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/features/user-path.mdx`:
- Around line 90-122: Update the “Self-service usage and limits” section in
user-path.mdx to document two missing behaviors for the `/v1/usage` endpoint:
polling it does not consume rate-limit quota, and `rate_limits` only returns
user_path-scoped rules. Add concise one-line bullets near the existing
usage/rate-limit explanation so callers like OpenCode-style pollers know they
can check frequently and understand why provider/model-scoped throttles are
absent. Keep the wording aligned with the existing descriptions of `/v1/usage`,
`usage`, and `rate_limits`.
In `@internal/app/app.go`:
- Around line 491-505: `usageReader` is not being cleared when `usage.NewReader`
fails, so a typed-nil can still slip through to `serverCfg.UsageSummarizer`.
Update the `usageReader` setup in `app.go` to mirror the `pricingRecalculator`
nil-reset pattern: in the `usage.NewReader` error path, explicitly set
`usageReader` back to nil before the later `if usageReader != nil` check. Use
the `usage.NewReader`, `usageReader`, and `serverCfg.UsageSummarizer` symbols to
locate the fix.
In `@internal/budget/service.go`:
- Around line 236-269: StatusesForPath currently drops already computed
CheckResult values when evaluateBudget returns an error, unlike CheckWithResults
which preserves partial results. Update the error path in
Service.StatusesForPath to return the accumulated results alongside the error,
and keep its loop/accumulator behavior aligned with CheckWithResults so both
methods handle partial failures consistently.
In `@internal/server/usage_status_handler_test.go`:
- Around line 192-204: Add a test that covers the documented 365-day cap for the
usage endpoint, since TestUsageStatusRejectsInvalidDates only checks malformed
and inverted ranges. Update internal/server/usage_status_handler_test.go by
adding a case around getUsageStatus with a fakeUsageSummarizer and Config that
requests an oversized window (for example days=400 or a start_date/end_date span
beyond 365 days) and assert the handler still returns OK while clamping the
translated range to the maximum window. Use the existing test helpers and
summarize parameters captured by fakeUsageSummarizer to verify the capped dates.
In `@internal/server/usage_status_handler.go`:
- Around line 170-188: `usageStatusWindow` currently ignores malformed or
non-positive `days` values and falls back to `usage.DefaultDateRangeDays`, which
makes validation inconsistent with `start_date` and `end_date`. Update the
`days` parsing branch in `usageStatusWindow` to return an error when
`c.QueryParam("days")` is present but cannot be parsed as a positive integer, so
the handler can respond with 400 instead of silently using the default window;
keep the existing `usage.BuildDateRange` flow for valid input.
In `@internal/usage/date_range.go`:
- Around line 1-68: The new shared date-range utility in BuildDateRange and
NormalizeDateRangeDays has no dedicated tests, so add a table-driven
date_range_test.go covering both bounds provided, only start, only end, neither
bound, start-after-end, invalid YYYY-MM-DD formats, and day clamping/defaulting
behavior. Make sure the tests exercise the exported helpers in
internal/usage/date_range.go so regressions in the reused /v1/usage and admin
dashboard date-window logic are caught.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: fb3bd537-2267-445d-a9d0-1028f1608a96
📒 Files selected for processing (17)
CLAUDE.mdcmd/gomodel/docs/docs.godocs/advanced/api-endpoints.mdxdocs/features/user-path.mdxdocs/openapi.jsoninternal/admin/handler.gointernal/admin/handler_usage.gointernal/app/app.gointernal/budget/service.gointernal/budget/service_test.gointernal/ratelimit/service.gointernal/ratelimit/service_test.gointernal/server/handlers.gointernal/server/http.gointernal/server/usage_status_handler.gointernal/server/usage_status_handler_test.gointernal/usage/date_range.go
| ## Self-service usage and limits | ||
|
|
||
| Callers can check their own consumption without admin access: | ||
|
|
||
| ```bash | ||
| curl http://localhost:8080/v1/usage \ | ||
| -H "Authorization: Bearer sk_gom_..." | ||
| ``` | ||
|
|
||
| The response covers the caller's effective user path: recorded usage over a | ||
| date window (default: the last 30 days, UTC), plus the status of every budget | ||
| and rate limit rule gating that path. | ||
|
|
||
| ```json | ||
| { | ||
| "user_path": "/team/alpha", | ||
| "server_time": "2026-07-06T17:00:00Z", | ||
| "usage": { "start_date": "2026-06-07", "end_date": "2026-07-06", "total_requests": 128, "total_tokens": 91234, "total_cost": 1.73, ... }, | ||
| "budgets": [ { "user_path": "/team/alpha", "period_label": "monthly", "amount": 100, "spent": 41.2, "remaining": 58.8, "exceeded": false, ... } ], | ||
| "rate_limits": [ { "user_path": "/team/alpha", "period_label": "minute", "max_requests": 60, "requests_used": 3, "requests_remaining": 57, ... } ] | ||
| } | ||
| ``` | ||
|
|
||
| - With a managed API key, the key's bound `user_path` is used; the endpoint | ||
| needs no extra parameters, so tools like OpenCode plugins can poll it with | ||
| the same key they use for inference. | ||
| - With the master key (or in unsafe mode), pass `X-GoModel-User-Path` to pick | ||
| the path, or omit it to see `/` (everything). | ||
| - `start_date`, `end_date` (YYYY-MM-DD), and `days` query parameters narrow | ||
| the usage window (365-day maximum). | ||
| - `usage` is `null` when no storage backend records usage; `budgets` and | ||
| `rate_limits` list only rules that cover the path, including ones inherited | ||
| from ancestor paths such as `/`. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Document two behaviors from the PR objectives that are missing here.
This section doesn't mention that:
- Polling
/v1/usagedoes not itself consume rate-limit quota — worth stating explicitly so callers (e.g., OpenCode-style pollers) don't hesitate to check status frequently. rate_limitsonly includesuser_path-scoped rules; provider/model-scoped rate limit rules are excluded — otherwise a caller could reasonably expect provider throttling to show up here and be confused by its absence.
Both are one-line additions that materially change how the endpoint should be interpreted.
📝 Suggested doc addition
- `usage` is `null` when no storage backend records usage; `budgets` and
`rate_limits` list only rules that cover the path, including ones inherited
from ancestor paths such as `/`.
+- `rate_limits` includes only `user_path`-scoped rules; provider- and
+ model-scoped rate limit rules are not reported here.
+- Polling this endpoint does not consume rate limit quota.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ## Self-service usage and limits | |
| Callers can check their own consumption without admin access: | |
| ```bash | |
| curl http://localhost:8080/v1/usage \ | |
| -H "Authorization: Bearer sk_gom_..." | |
| ``` | |
| The response covers the caller's effective user path: recorded usage over a | |
| date window (default: the last 30 days, UTC), plus the status of every budget | |
| and rate limit rule gating that path. | |
| ```json | |
| { | |
| "user_path": "/team/alpha", | |
| "server_time": "2026-07-06T17:00:00Z", | |
| "usage": { "start_date": "2026-06-07", "end_date": "2026-07-06", "total_requests": 128, "total_tokens": 91234, "total_cost": 1.73, ... }, | |
| "budgets": [ { "user_path": "/team/alpha", "period_label": "monthly", "amount": 100, "spent": 41.2, "remaining": 58.8, "exceeded": false, ... } ], | |
| "rate_limits": [ { "user_path": "/team/alpha", "period_label": "minute", "max_requests": 60, "requests_used": 3, "requests_remaining": 57, ... } ] | |
| } | |
| ``` | |
| - With a managed API key, the key's bound `user_path` is used; the endpoint | |
| needs no extra parameters, so tools like OpenCode plugins can poll it with | |
| the same key they use for inference. | |
| - With the master key (or in unsafe mode), pass `X-GoModel-User-Path` to pick | |
| the path, or omit it to see `/` (everything). | |
| - `start_date`, `end_date` (YYYY-MM-DD), and `days` query parameters narrow | |
| the usage window (365-day maximum). | |
| - `usage` is `null` when no storage backend records usage; `budgets` and | |
| `rate_limits` list only rules that cover the path, including ones inherited | |
| from ancestor paths such as `/`. | |
| ## Self-service usage and limits | |
| Callers can check their own consumption without admin access: | |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/features/user-path.mdx` around lines 90 - 122, Update the “Self-service
usage and limits” section in user-path.mdx to document two missing behaviors for
the `/v1/usage` endpoint: polling it does not consume rate-limit quota, and
`rate_limits` only returns user_path-scoped rules. Add concise one-line bullets
near the existing usage/rate-limit explanation so callers like OpenCode-style
pollers know they can check frequently and understand why provider/model-scoped
throttles are absent. Keep the wording aligned with the existing descriptions of
`/v1/usage`, `usage`, and `rate_limits`.
Source: Coding guidelines
| package usage | ||
|
|
||
| import ( | ||
| "time" | ||
|
|
||
| "gomodel/internal/core" | ||
| ) | ||
|
|
||
| const ( | ||
| // DefaultDateRangeDays is the usage window applied when a query gives no | ||
| // explicit range. | ||
| DefaultDateRangeDays = 30 | ||
| // MaxDateRangeDays caps a requested usage window. | ||
| MaxDateRangeDays = 365 | ||
| ) | ||
|
|
||
| // BuildDateRange resolves an inclusive [start, end] day range from optional | ||
| // YYYY-MM-DD strings. When only one bound is given the other defaults (start: | ||
| // 30 days before end, end: today); when neither is given the range covers the | ||
| // last days days ending today. | ||
| func BuildDateRange(startStr, endStr string, days int, location *time.Location, today time.Time) (time.Time, time.Time, error) { | ||
| var start, end time.Time | ||
| var startParsed, endParsed bool | ||
|
|
||
| if startStr != "" { | ||
| t, err := time.ParseInLocation("2006-01-02", startStr, location) | ||
| if err != nil { | ||
| return time.Time{}, time.Time{}, core.NewInvalidRequestError("invalid start_date format, expected YYYY-MM-DD", nil) | ||
| } | ||
| start = t | ||
| startParsed = true | ||
| } | ||
| if endStr != "" { | ||
| t, err := time.ParseInLocation("2006-01-02", endStr, location) | ||
| if err != nil { | ||
| return time.Time{}, time.Time{}, core.NewInvalidRequestError("invalid end_date format, expected YYYY-MM-DD", nil) | ||
| } | ||
| end = t | ||
| endParsed = true | ||
| } | ||
|
|
||
| if startParsed || endParsed { | ||
| if !startParsed { | ||
| start = end.AddDate(0, 0, -(DefaultDateRangeDays - 1)) | ||
| } | ||
| if !endParsed { | ||
| end = today | ||
| } | ||
| } else { | ||
| days = NormalizeDateRangeDays(days) | ||
| end = today | ||
| start = today.AddDate(0, 0, -(days - 1)) | ||
| } | ||
|
|
||
| if start.After(end) { | ||
| return time.Time{}, time.Time{}, core.NewInvalidRequestError("start_date must be on or before end_date", nil) | ||
| } | ||
| return start, end, nil | ||
| } | ||
|
|
||
| // NormalizeDateRangeDays clamps days to [1, MaxDateRangeDays], defaulting to | ||
| // DefaultDateRangeDays when not positive. | ||
| func NormalizeDateRangeDays(days int) int { | ||
| if days <= 0 { | ||
| return DefaultDateRangeDays | ||
| } | ||
| return min(days, MaxDateRangeDays) | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
No dedicated tests for the new shared date-range utility.
This new package centralizes date-window logic reused by the self-service /v1/usage endpoint and the admin dashboard, but no date_range_test.go is included. Given the edge-case bug above, table-driven tests covering both-given/one-given/neither-given branches, boundary clamping, and invalid formats would help catch regressions.
As per coding guidelines: "Add or update tests for behavior changes, especially request translation, response normalization, error handling, default configuration, and provider-specific parameter mapping."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/usage/date_range.go` around lines 1 - 68, The new shared date-range
utility in BuildDateRange and NormalizeDateRangeDays has no dedicated tests, so
add a table-driven date_range_test.go covering both bounds provided, only start,
only end, neither bound, start-after-end, invalid YYYY-MM-DD formats, and day
clamping/defaulting behavior. Make sure the tests exercise the exported helpers
in internal/usage/date_range.go so regressions in the reused /v1/usage and admin
dashboard date-window logic are caught.
Source: Coding guidelines
Full GET /v1/usage walkthrough (quick example, path selection, field reference, date window, errors) under Advanced, linked from the endpoint reference and the user-path page, whose inline section now defers to it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| if start.After(end) { | ||
| return time.Time{}, time.Time{}, core.NewInvalidRequestError("start_date must be on or before end_date", nil) | ||
| } | ||
| return start, end, nil |
There was a problem hiding this comment.
Missing range cap
BuildDateRange never enforces MaxDateRangeDays after explicit start_date and end_date parsing, so GET /v1/usage?start_date=2020-01-01&end_date=2026-01-01 is accepted even though the endpoint documents a 365-day cap. Because /v1/usage passes caller-supplied dates directly into usage aggregation, any authenticated key can trigger oversized historical scans instead of receiving a bounded window.
Context Used: CLAUDE.md (source)
Artifacts
Repro: Go harness source calling BuildDateRange with an oversized explicit range
- Contains supporting evidence from the run (text/x-go; charset=utf-8).
Repro: harness execution output showing the 2193-day range was accepted with nil error
- Keeps the command output available without making the summary code-heavy.
Review follow-ups on #498: - BuildDateRange now rejects explicit start_date/end_date ranges longer than 365 days (Greptile P1: the documented cap only clamped the days param, so any key could request unbounded historical scans). AddDate keeps the check correct across DST transitions. - A malformed or non-positive days value returns 400 instead of silently falling back to the default window, matching the start_date/end_date validation on the same endpoint. - app wiring resets the usage reader to nil on constructor errors so a typed-nil can never reach the UsageSummarizer nil check. - budget.StatusesForPath returns partial results alongside the error, matching its sibling CheckWithResults. - Table-driven tests for the shared date-range helper, handler tests for the cap and days validation, docs and swagger updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
@coderabbitai continue |
|
✅ Action performedReview finished.
|
Ecosystem research showed the one idiom every consumer-facing limits surface converged on (Codex, Claude Code, OpenCode) is percent-of-window plus time-to-reset. Expose both as derived fields so status displays need no client-side math or clock-skew handling: - budgets: usage_ratio (spent/amount, unclamped like the admin API) and resets_in_seconds counting down to period_end - rate limits: requests_usage_ratio / tokens_usage_ratio per limited dimension (in-flight feeds the concurrent ratio), an exhausted flag mirroring admission, and resets_in_seconds to window_end (omitted for concurrent rules) Relative resets are computed against server_time, so clients can render countdowns without trusting their own clocks. Also covers the error branches of StatusesForPath/StatusesForUserPath flagged by codecov. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/server/usage_status_handler_test.go`:
- Around line 147-224: TestUsageStatusDerivedFields only covers zero remaining
values, so add a new assertion case for a negative overshoot remaining value to
lock in the corrected Exhausted behavior. Extend the existing setup in
TestUsageStatusDerivedFields around the fakeRateLimiterWithStatus/status checks
to include a rule where RequestsRemaining or TokensRemaining is below zero, then
verify the derived response from usage status still marks the rule exhausted and
preserves the expected usage ratios.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3a58e6b7-bcb5-40da-9eb6-6283d1b2e96e
📒 Files selected for processing (7)
cmd/gomodel/docs/docs.godocs/advanced/usage-api.mdxdocs/openapi.jsoninternal/budget/service_test.gointernal/ratelimit/service_test.gointernal/server/usage_status_handler.gointernal/server/usage_status_handler_test.go
| func TestUsageStatusDerivedFields(t *testing.T) { | ||
| maxRequests := int64(10) | ||
| maxTokens := int64(100) | ||
| requestsLeft := int64(7) | ||
| tokensLeft := int64(0) | ||
| concurrentMax := int64(2) | ||
| concurrentLeft := int64(0) | ||
| now := time.Now().UTC() | ||
| windowEnd := now.Add(45 * time.Second) | ||
| periodEnd := now.Add(90 * time.Minute) | ||
|
|
||
| budgets := &fakeBudgetStatusChecker{results: []budget.CheckResult{{ | ||
| Budget: budget.Budget{UserPath: "/team", PeriodSeconds: budget.PeriodDailySeconds, Amount: 10}, | ||
| PeriodStart: periodEnd.Add(-24 * time.Hour), | ||
| PeriodEnd: periodEnd, | ||
| Spent: 12, | ||
| HasUsage: true, | ||
| Remaining: -2, | ||
| }}} | ||
| limiter := &fakeRateLimiterWithStatus{statuses: []ratelimit.Status{ | ||
| { | ||
| Rule: ratelimit.Rule{Scope: ratelimit.ScopeUserPath, Subject: "/team", PeriodSeconds: 60, MaxRequests: &maxRequests, MaxTokens: &maxTokens}, | ||
| WindowStart: windowEnd.Add(-time.Minute), | ||
| WindowEnd: windowEnd, | ||
| RequestsUsed: 3, | ||
| RequestsRemaining: &requestsLeft, | ||
| TokensUsed: 120, | ||
| TokensRemaining: &tokensLeft, | ||
| }, | ||
| { | ||
| Rule: ratelimit.Rule{Scope: ratelimit.ScopeUserPath, Subject: "/team", PeriodSeconds: ratelimit.PeriodConcurrent, MaxRequests: &concurrentMax}, | ||
| InFlight: 2, | ||
| RequestsRemaining: &concurrentLeft, | ||
| }, | ||
| }} | ||
|
|
||
| rec, body := getUsageStatus(t, &Config{BudgetChecker: budgets, RateLimiter: limiter}, "/v1/usage", nil) | ||
| if rec.Code != http.StatusOK { | ||
| t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String()) | ||
| } | ||
|
|
||
| if len(body.Budgets) != 1 { | ||
| t.Fatalf("budgets = %d, want 1", len(body.Budgets)) | ||
| } | ||
| b := body.Budgets[0] | ||
| if b.UsageRatio != 1.2 { | ||
| t.Fatalf("budget usage_ratio = %v, want 1.2 (unclamped)", b.UsageRatio) | ||
| } | ||
| if b.ResetsInSeconds <= 85*60 || b.ResetsInSeconds > 90*60 { | ||
| t.Fatalf("budget resets_in_seconds = %d, want ~90 minutes", b.ResetsInSeconds) | ||
| } | ||
|
|
||
| if len(body.RateLimits) != 2 { | ||
| t.Fatalf("rate_limits = %d, want 2", len(body.RateLimits)) | ||
| } | ||
| windowed, concurrent := body.RateLimits[0], body.RateLimits[1] | ||
| if windowed.RequestsUsageRatio == nil || *windowed.RequestsUsageRatio != 3.0/10.0 { | ||
| t.Fatalf("windowed requests_usage_ratio = %v, want 0.3", windowed.RequestsUsageRatio) | ||
| } | ||
| if windowed.TokensUsageRatio == nil || *windowed.TokensUsageRatio != 1.2 { | ||
| t.Fatalf("windowed tokens_usage_ratio = %v, want 1.2 (unclamped)", windowed.TokensUsageRatio) | ||
| } | ||
| if !windowed.Exhausted { | ||
| t.Fatal("windowed rule with zero tokens remaining must be exhausted") | ||
| } | ||
| if windowed.ResetsInSeconds == nil || *windowed.ResetsInSeconds <= 0 || *windowed.ResetsInSeconds > 45 { | ||
| t.Fatalf("windowed resets_in_seconds = %v, want within (0, 45]", windowed.ResetsInSeconds) | ||
| } | ||
| if concurrent.RequestsUsageRatio == nil || *concurrent.RequestsUsageRatio != 1.0 { | ||
| t.Fatalf("concurrent requests_usage_ratio = %v, want 1.0 (from in-flight)", concurrent.RequestsUsageRatio) | ||
| } | ||
| if !concurrent.Exhausted { | ||
| t.Fatal("concurrent rule at capacity must be exhausted") | ||
| } | ||
| if concurrent.ResetsInSeconds != nil || concurrent.TokensUsageRatio != nil { | ||
| t.Fatalf("concurrent rule resets/tokens ratio = %v/%v, want both omitted", concurrent.ResetsInSeconds, concurrent.TokensUsageRatio) | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
Add coverage for negative/overshoot remaining once Exhausted is fixed.
TestUsageStatusDerivedFields exercises RequestsRemaining/TokensRemaining at exactly 0, but not negative overshoot values. Once the Exhausted strict-equality issue in usage_status_handler.go is addressed, add a case with a negative remaining value to lock in the corrected behavior.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/server/usage_status_handler_test.go` around lines 147 - 224,
TestUsageStatusDerivedFields only covers zero remaining values, so add a new
assertion case for a negative overshoot remaining value to lock in the corrected
Exhausted behavior. Extend the existing setup in TestUsageStatusDerivedFields
around the fakeRateLimiterWithStatus/status checks to include a rule where
RequestsRemaining or TokensRemaining is below zero, then verify the derived
response from usage status still marks the rule exhausted and preserves the
expected usage ratios.
Closes #466.
What
Adds
GET /v1/usageso callers can read their own usage, budget, and rate limit status with the same API key they use for inference — no admin access needed. This is the building block for tools like OpenCode plugins that want to show consumption and remaining budget.User-visible behavior
The response is scoped to the caller's effective user path — the path bound to the managed API key, or the
X-GoModel-User-Pathheader for master-key callers (/when omitted):{ "user_path": "/team/alpha", "server_time": "2026-07-06T17:00:00Z", "usage": { "start_date": "2026-06-07", "end_date": "2026-07-06", "total_requests": 128, "total_tokens": 91234, "total_cost": 1.73, ... }, "budgets": [ { "user_path": "/team/alpha", "period_label": "monthly", "amount": 100, "spent": 41.2, "remaining": 58.8, "exceeded": false, ... } ], "rate_limits": [ { "user_path": "/team/alpha", "period_label": "minute", "max_requests": 60, "requests_used": 3, "requests_remaining": 57, ... } ] }usage: summary over a date window (start_date/end_date/daysquery params, default last 30 days UTC, 365-day cap — same semantics as the dashboard).nullwhen no storage backend records usage.budgets: every budget covering the path, including ones inherited from ancestor paths, with spent/remaining/period bounds and anexceededflag mirroring enforcement (budgets without usage never block).rate_limits: live counters for user-path rules covering the path (subtree match). Provider/model rules are excluded — they describe shared infrastructure capacity, not the consumer.null); genuine store failures return 503.Design
Per-user-path (not per-key) is the identity on purpose: usage entries, budgets, and rate limits are all keyed by user path, and a future users system maps naturally to user → user_path, keeping this endpoint stable. Deliberate tradeoff: ancestor budgets (including a global
/budget) are shown with full spent/remaining to any covered key holder, since "when will I be cut off" is exactly what the caller needs.Implementation
budget.Service.StatusesForPath(evaluates all matching budgets, no early return at the first exceeded one) andratelimit.Service.StatusesForUserPath.BudgetChecker/RateLimiterfields, so enforcement-only fakes and extensions keep working./v1/usagedeliberately is not.admintousage.BuildDateRange(now used by both), and the usage reader is created once in app wiring and shared by the server and admin handlers./admin/rate-limitsendpoints from feat(ratelimit): scoped request, token, and concurrency limits (user paths, providers, models) #482 that were never regenerated into the checked-in docs — that's the unrelated-looking part ofdocs/openapi.json/cmd/gomodel/docs/docs.go.Testing
🤖 Generated with Claude Code
Summary by CodeRabbit
GET /v1/usageto return your usage totals plus applicable budget and rate-limit status, scoped to your effective user path, with a configurable UTC date window.