feat: github quota & refactor quota types#1470
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for GitHub Copilot quota tracking, including backend integration with the GitHub API, database schema updates, and frontend UI components to display usage metrics like chat messages and inline suggestions. A bug was identified in the logic for calculating remaining percentages for limited user quotas, where a zero-remaining balance might fail to trigger an 'exhausted' status if the total quota is also zero or missing.
Greptile SummaryThis PR adds GitHub Copilot quota checking and display, and refactors the frontend quota types into per-provider discriminated unions. The backend checker, frontend display logic, and schema enum update all look well-structured.
Confidence Score: 4/5Safe to merge after addressing the plain-token quota-check bypass; all other changes are well-scoped. One P1 defect: GitHub Copilot channels using a plain access token silently skip quota checks due to the credential-type guard in checkChannelQuota. The remaining P2 finding (parseResetDate fallback) is unlikely to cause observable errors today. internal/server/biz/provider_quota.go (credential guard) and internal/server/biz/provider_quota/github_copilot_checker.go (parseResetDate) Important Files Changed
Sequence DiagramsequenceDiagram
participant Scheduler
participant QuotaService as ProviderQuotaService
participant Checker as GithubCopilotChecker
participant API as GitHub API
participant DB
Scheduler->>QuotaService: runQuotaCheck()
QuotaService->>QuotaService: query enabled channels
loop for each channel
QuotaService->>QuotaService: checkChannelQuota(ch)
Note over QuotaService: Guard: skip if no OAuth and not OAuth JSON
QuotaService->>Checker: CheckQuota(ctx, ch)
Checker->>Checker: getAccessToken(ch)
Checker->>API: GET /copilot_internal/user
API-->>Checker: copilotUserPayload
Checker->>Checker: calculateStatus(payload)
Checker-->>QuotaService: QuotaData
QuotaService->>DB: upsert ProviderQuotaStatus
end
Note over DB: Frontend polls every 60s via GraphQL
|
|
|
||
| {quota.nextResetAt && ( |
There was a problem hiding this comment.
Unexplained magic division by 10
rem and tot are divided by 10 before display, but there is no comment explaining why. If GitHub Copilot's limited_user_quotas returns values in units of 1/10 requests (e.g. 20000 = 2000 requests), this is correct — but without documentation a future maintainer (or reviewer) cannot verify it. Please add an inline comment documenting the unit conversion, or confirm the values are already in whole requests and remove the division.
| } | ||
|
|
||
| return items; | ||
| })()} | ||
|
|
||
| {quota.nextResetAt && ( | ||
| <div className="text-[11px] text-muted-foreground text-right pt-1"> | ||
| {t('quota.label.resets_in')} {formatTimeToReset(quota.nextResetAt)} ({formatDate(new Date(quota.nextResetAt).getTime() / 1000)}) | ||
| </div> | ||
| )} | ||
| </div> | ||
| )} | ||
|
|
||
| {channel.type === 'codex' && ( | ||
| <div className="mt-4 space-y-4"> | ||
| {quotaData.rate_limit?.primary_window && ( | ||
| <div className="space-y-2.5"> | ||
| <div className="space-y-1"> | ||
| <div className="flex justify-between items-center text-xs"> | ||
| <span className="font-medium text-muted-foreground">{t('quota.label.primary_window')}</span> | ||
| <span className="font-medium text-foreground">{Math.round(quotaData.rate_limit.primary_window.used_percent || 0)}%</span> | ||
| </div> | ||
| <ProgressBar | ||
| percentage={quotaData.rate_limit.primary_window.used_percent || 0} | ||
| durationPercentage={quotaData.rate_limit.primary_window.limit_window_seconds ? calcDurationPercent(quotaData.rate_limit.primary_window.limit_window_seconds, quotaData.rate_limit.primary_window.reset_after_seconds) : undefined} | ||
| /> | ||
| </div> | ||
|
|
||
| {quotaData.rate_limit.primary_window.limit_window_seconds ? ( | ||
| <div className="space-y-1"> | ||
| <div className="flex justify-between items-center text-xs"> | ||
| <span className="font-medium text-muted-foreground"> | ||
| {t('quota.label.primary_duration')} ({formatWindowDuration(quotaData.rate_limit.primary_window.limit_window_seconds)}) | ||
| </span> | ||
| <span className="font-medium text-foreground">{Math.round(calcDurationPercent(quotaData.rate_limit.primary_window.limit_window_seconds, quotaData.rate_limit.primary_window.reset_after_seconds))}%</span> | ||
| {(() => { | ||
| const qd = channel.quotaStatus?.quotaData | ||
| if (!qd) return null; | ||
| return ( | ||
| <> | ||
| {qd.rate_limit?.primary_window && ( | ||
| <div className="space-y-2.5"> | ||
| <div className="space-y-1"> | ||
| <div className="flex justify-between items-center text-xs"> | ||
| <span className="font-medium text-muted-foreground">{t('quota.label.primary_window')}</span> | ||
| <span className="font-medium text-foreground">{Math.round(qd.rate_limit.primary_window.used_percent || 0)}%</span> | ||
| </div> | ||
| <ProgressBar | ||
| percentage={qd.rate_limit.primary_window.used_percent || 0} | ||
| durationPercentage={qd.rate_limit.primary_window.limit_window_seconds ? calcDurationPercent(qd.rate_limit.primary_window.limit_window_seconds, qd.rate_limit.primary_window.reset_after_seconds) : undefined} | ||
| /> | ||
| </div> | ||
|
|
||
| {qd.rate_limit.primary_window.limit_window_seconds ? ( | ||
| <div className="space-y-1"> | ||
| <div className="flex justify-between items-center text-xs"> | ||
| <span className="font-medium text-muted-foreground"> | ||
| {t('quota.label.primary_duration')} ({formatWindowDuration(qd.rate_limit.primary_window.limit_window_seconds)}) | ||
| </span> | ||
| <span className="font-medium text-foreground">{Math.round(calcDurationPercent(qd.rate_limit.primary_window.limit_window_seconds, qd.rate_limit.primary_window.reset_after_seconds))}%</span> | ||
| </div> | ||
| <ProgressBar | ||
| type="duration" | ||
| percentage={calcDurationPercent(qd.rate_limit.primary_window.limit_window_seconds, qd.rate_limit.primary_window.reset_after_seconds)} | ||
| /> | ||
| </div> | ||
| ) : null} | ||
|
|
||
| {qd.rate_limit.primary_window.reset_at && ( | ||
| <div className="text-[11px] text-muted-foreground text-right pt-0.5"> | ||
| {t('quota.label.resets_in')} {formatTimeToReset(qd.rate_limit.primary_window.reset_after_seconds)} ({formatDate(qd.rate_limit.primary_window.reset_at)}) | ||
| </div> | ||
| )} | ||
| </div> | ||
| <ProgressBar | ||
| type="duration" | ||
| percentage={calcDurationPercent(quotaData.rate_limit.primary_window.limit_window_seconds, quotaData.rate_limit.primary_window.reset_after_seconds)} | ||
| /> | ||
| </div> | ||
| ) : null} | ||
|
|
||
| {quotaData.rate_limit.primary_window.reset_at && ( | ||
| <div className="text-[11px] text-muted-foreground text-right pt-0.5"> | ||
| {t('quota.label.resets_in', { defaultValue: 'Resets in' })} {formatTimeToReset(quotaData.rate_limit.primary_window.reset_after_seconds)} ({formatDate(quotaData.rate_limit.primary_window.reset_at)}) | ||
| </div> | ||
| )} | ||
| </div> | ||
| )} | ||
| )} | ||
|
|
||
| {quotaData.rate_limit?.secondary_window?.used_percent !== undefined && ( | ||
| <div className="space-y-2.5 pt-3 mt-3 border-t border-dashed border-border/60"> | ||
| <div className="space-y-1"> | ||
| <div className="flex justify-between items-center text-xs"> | ||
| <span className="font-medium text-muted-foreground">{t('quota.label.secondary_window')}</span> | ||
| <span className="font-medium text-foreground">{Math.round(quotaData.rate_limit.secondary_window.used_percent)}%</span> | ||
| </div> | ||
| <ProgressBar | ||
| percentage={quotaData.rate_limit.secondary_window.used_percent} | ||
| durationPercentage={quotaData.rate_limit.secondary_window.limit_window_seconds ? calcDurationPercent(quotaData.rate_limit.secondary_window.limit_window_seconds, quotaData.rate_limit.secondary_window.reset_after_seconds) : undefined} | ||
| /> | ||
| </div> | ||
|
|
||
| {quotaData.rate_limit.secondary_window.limit_window_seconds ? ( | ||
| <div className="space-y-1"> | ||
| <div className="flex justify-between items-center text-xs"> | ||
| <span className="font-medium text-muted-foreground"> | ||
| {t('quota.label.secondary_duration')} ({formatWindowDuration(quotaData.rate_limit.secondary_window.limit_window_seconds)}) | ||
| </span> | ||
| <span className="font-medium text-foreground">{Math.round(calcDurationPercent(quotaData.rate_limit.secondary_window.limit_window_seconds, quotaData.rate_limit.secondary_window.reset_after_seconds))}%</span> | ||
| {qd.rate_limit?.secondary_window?.used_percent !== undefined && ( | ||
| <div className="space-y-2.5 pt-3 mt-3 border-t border-dashed border-border/60"> | ||
| <div className="space-y-1"> | ||
| <div className="flex justify-between items-center text-xs"> | ||
| <span className="font-medium text-muted-foreground">{t('quota.label.secondary_window')}</span> |
There was a problem hiding this comment.
Potential duplicate React keys across quota sources
Both limited_user_quotas and quota_snapshots are iterated and each entry is pushed into the same items array using the map key as the React list key. If both objects contain the same key (e.g. completions or chat), React will emit a duplicate-key warning and may render items incorrectly. Although in practice these two maps target different plan tiers, certain account types (EDU or transition states) may carry both simultaneously. Consider prefixing the keys (e.g. limited_completions vs snapshot_completions) to guarantee uniqueness.
There was a problem hiding this comment.
Pull request overview
Adds GitHub Copilot as a supported provider for quota status checking, and refactors quota data typing/handling across backend + GraphQL schema + frontend UI.
Changes:
- Add a backend GitHub Copilot quota checker that calls GitHub’s Copilot internal user endpoint and maps results into unified quota status.
- Extend Ent/GraphQL provider quota enums and migrations to include
github_copilot. - Refactor frontend quota types and update quota badge UI to render GitHub Copilot quota details + new i18n strings.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| llm/transformer/openai/copilot/outbound.go | Exports SetCopilotHeaders for reuse by quota checker. |
| internal/server/gql/ent.graphql | Adds github_copilot to GraphQL enum for provider quota status. |
| internal/server/biz/provider_quota/github_copilot_checker.go | Introduces GitHub Copilot quota checker and parsing/status logic. |
| internal/server/biz/provider_quota.go | Registers GitHub Copilot checker and includes channel type in quota check query. |
| internal/ent/schema/provider_quota_status.go | Adds github_copilot enum value to Ent schema. |
| internal/ent/providerquotastatus/providerquotastatus.go | Adds ProviderTypeGithubCopilot constant + validator support. |
| internal/ent/migrate/schema.go | Updates migration schema enum list to include github_copilot. |
| internal/ent/internal/schema.go | Updates embedded Ent schema snapshot. |
| frontend/src/locales/zh-CN/system.json | Adds Copilot quota labels + refresh label (ZH). |
| frontend/src/locales/en/system.json | Adds Copilot quota labels + refresh label (EN). |
| frontend/src/features/system/data/quotas.ts | Adds typed quota data models and includes Copilot in OAuth quota list. |
| frontend/src/components/quota-badges.tsx | Updates quota UI to compute/render Copilot quota details and adds i18n aria-label. |
Comments suppressed due to low confidence (1)
frontend/src/features/system/data/quotas.ts:160
oauthChannelsfiltering lowercasesc.type(and the comment mentions PascalCase types), but the mapped result returnstype: channel.typewithout normalizing. Downstream code (e.g. quota UI) compares against lowercase literals like'claudecode', so a PascalCasetypewould pass the filter but then fail rendering/percentage logic. Consider normalizingtypeto lowercase when mapping (and casting to the union type).
// Filter for OAuth channels (claudecode, codex, github_copilot) - check both lowercase and PascalCase
const oauthChannels = channels.filter((c: any) => {
const type = c.type?.toLowerCase();
const match = ['claudecode', 'codex', 'github_copilot'].includes(type);
return match;
});
// Map to standard format - providerQuotaStatus is a single object, not an edge/node structure
return oauthChannels.map((channel: any): ProviderQuotaChannel => {
const quotaStatus = channel.providerQuotaStatus;
return {
id: channel.id,
name: channel.name,
type: channel.type,
quotaStatus,
};
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const total = totalQuotas?.[key] ?? remaining; | ||
| if (total > 0) { | ||
| lowestRemaining = Math.min(lowestRemaining, (remaining / total) * 100); |
| type GithubCopilotQuotaChecker struct { | ||
| httpClient *httpclient.HttpClient | ||
| } | ||
|
|
||
| func NewGithubCopilotQuotaChecker(httpClient *httpclient.HttpClient) *GithubCopilotQuotaChecker { | ||
| return &GithubCopilotQuotaChecker{ | ||
| httpClient: httpClient, | ||
| } | ||
| } |
| for key, remainingVal := range payload.LimitedUserQuotas { | ||
| if remaining, ok := c.getNumber(remainingVal); ok { | ||
| total := remaining | ||
| if payload.MonthlyQuotas != nil { | ||
| if t, ok := c.getNumber(payload.MonthlyQuotas[key]); ok && t > 0 { | ||
| total = t | ||
| } | ||
| } | ||
| if total > 0 { | ||
| pct := (remaining / total) * 100 | ||
| if pct < lowestPercentage { | ||
| lowestPercentage = pct | ||
| } | ||
| } | ||
| } |
| if userResp.StatusCode < 200 || userResp.StatusCode >= 300 { | ||
| return nil, fmt.Errorf("failed to fetch copilot user info, status: %d", userResp.StatusCode) | ||
| } |
| func (c *GithubCopilotQuotaChecker) CheckQuota(ctx context.Context, ch *ent.Channel) (QuotaData, error) { | ||
| accessToken, err := c.getAccessToken(ch) | ||
| if err != nil { | ||
| return QuotaData{}, err | ||
| } | ||
|
|
||
| hc := c.httpClient | ||
| if ch.Settings != nil && ch.Settings.Proxy != nil { | ||
| hc = c.httpClient.WithProxy(ch.Settings.Proxy) | ||
| } | ||
|
|
||
| payload, err := c.fetchUserPayload(ctx, hc, accessToken) | ||
| if err != nil { | ||
| return QuotaData{}, err | ||
| } | ||
|
|
||
| status, _ := c.calculateStatus(payload) | ||
|
|
||
| return QuotaData{ | ||
| Status: status, | ||
| ProviderType: "github_copilot", | ||
| RawData: c.prepareRawData(payload), | ||
| NextResetAt: c.parseResetDate(payload), | ||
| Ready: status == "available" || status == "warning", | ||
| }, nil | ||
| } |
|
感谢 PR,是否可以合并了 |
|
可以了 |
Related issue: #1368