Skip to content

feat: github quota & refactor quota types#1470

Merged
looplj merged 5 commits into
looplj:unstablefrom
LazuliKao:feat/github-quota
Apr 24, 2026
Merged

feat: github quota & refactor quota types#1470
looplj merged 5 commits into
looplj:unstablefrom
LazuliKao:feat/github-quota

Conversation

@LazuliKao

@LazuliKao LazuliKao commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Related issue: #1368

  • 增加GitHub Copilot的配额查询
  • 优化配额查询的前端的TypeScript类型定义
image image

@gemini-code-assist gemini-code-assist Bot left a comment

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.

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.

Comment thread internal/server/biz/provider_quota/github_copilot_checker.go
@LazuliKao LazuliKao marked this pull request as ready for review April 24, 2026 06:57
Copilot AI review requested due to automatic review settings April 24, 2026 06:57
@greptile-apps

greptile-apps Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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.

  • P1 — Quota checks silently skipped for plain-token Copilot channels: checkChannelQuota in provider_quota.go returns early when OAuth == nil && !isOAuthJSON(APIKey), but GithubCopilotQuotaChecker.getAccessToken() explicitly supports plain PATs as a fallback. Any Copilot channel configured with a raw GitHub token will never receive quota updates.

Confidence Score: 4/5

Safe 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

Filename Overview
internal/server/biz/provider_quota.go Registers and dispatches GitHub Copilot checker, but the credential-type guard (line 262) blocks quota checks for Copilot channels using plain access tokens
internal/server/biz/provider_quota/github_copilot_checker.go New GitHub Copilot quota checker; contains minor reset-date parsing fragility and is blocked from running for plain-token channels due to the gate in provider_quota.go
frontend/src/features/system/data/quotas.ts QuotaData types refactored into per-provider types; discriminated union on ProviderQuotaChannel looks correct
frontend/src/components/quota-badges.tsx Adds GitHub Copilot display block; quota percentage logic correctly handles both limited_user_quotas and quota_snapshots paths
internal/ent/schema/provider_quota_status.go Adds github_copilot to the provider_type enum; schema change is straightforward
llm/transformer/openai/copilot/outbound.go Exports SetCopilotHeaders as a shared helper used by the new quota checker; no other logic changes

Sequence Diagram

sequenceDiagram
    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
Loading

Comments Outside Diff (1)

  1. internal/server/biz/provider_quota.go, line 262-265 (link)

    P1 GitHub Copilot plain-token channels silently skip quota checks

    The early-return guard bails out when ch.Credentials.OAuth == nil && !isOAuthJSON(ch.Credentials.APIKey). isOAuthJSON returns true only for JSON blobs that start with { and contain "access_token". A plain GitHub personal access token (e.g. ghp_xxx…) passes neither condition, so the function returns before the checker is ever invoked.

    Meanwhile, GithubCopilotQuotaChecker.getAccessToken() explicitly handles this as a supported code path:

    // fallback to using the api key itself as the token
    token := strings.TrimSpace(ch.Credentials.APIKey)

    Any GitHub Copilot channel configured with a raw PAT instead of an OAuth credential or OAuth JSON blob will silently receive no quota updates. Consider adding a ch.Type == channel.TypeGithubCopilot bypass to the guard, or moving the guard into the individual checkers that require OAuth.

Reviews (2): Last reviewed commit: "fix: fix scrollbar position" | Re-trigger Greptile

Comment on lines +392 to +393

{quota.nextResetAt && (

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.

P2 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.

Comment on lines +388 to +448
}

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>

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.

P1 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.

Comment thread frontend/src/locales/en/system.json

Copilot AI left a comment

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.

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

  • oauthChannels filtering lowercases c.type (and the comment mentions PascalCase types), but the mapped result returns type: channel.type without normalizing. Downstream code (e.g. quota UI) compares against lowercase literals like 'claudecode', so a PascalCase type would pass the filter but then fail rendering/percentage logic. Consider normalizing type to 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.

Comment on lines +64 to +66
const total = totalQuotas?.[key] ?? remaining;
if (total > 0) {
lowestRemaining = Math.min(lowestRemaining, (remaining / total) * 100);
Comment on lines +17 to +25
type GithubCopilotQuotaChecker struct {
httpClient *httpclient.HttpClient
}

func NewGithubCopilotQuotaChecker(httpClient *httpclient.HttpClient) *GithubCopilotQuotaChecker {
return &GithubCopilotQuotaChecker{
httpClient: httpClient,
}
}
Comment on lines +181 to +195
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
}
}
}
Comment on lines +102 to +104
if userResp.StatusCode < 200 || userResp.StatusCode >= 300 {
return nil, fmt.Errorf("failed to fetch copilot user info, status: %d", userResp.StatusCode)
}
Comment on lines +38 to +63
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
}
@looplj

looplj commented Apr 24, 2026

Copy link
Copy Markdown
Owner

感谢 PR,是否可以合并了

@LazuliKao

Copy link
Copy Markdown
Contributor Author

可以了

@looplj looplj merged commit 4ccfe46 into looplj:unstable Apr 24, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants