Repository navigation
fix(rate-limits): stop showing Gemini failures as Antigravity "Refresh failed" #15876
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import type { ProviderRateLimits, ProviderRateLimitStatus } from '../../shared/rate-limit-types' | ||
| import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' | ||
|
|
||
| function geminiSnapshot( | ||
| status: ProviderRateLimitStatus, | ||
| error: string | null, | ||
| usedPercent: number | null = null | ||
| ): ProviderRateLimits { | ||
| return { | ||
| provider: 'gemini', | ||
| session: | ||
| usedPercent === null | ||
| ? null | ||
| : { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, | ||
| weekly: null, | ||
| updatedAt: 1_700_000_000_000, | ||
| error, | ||
| status | ||
| } | ||
| } | ||
|
|
||
| describe('deriveAntigravityRateLimits', () => { | ||
| it('mirrors a successful Gemini read as shared Code Assist quota', () => { | ||
| const antigravity = deriveAntigravityRateLimits(geminiSnapshot('ok', null, 42)) | ||
|
|
||
| expect(antigravity.provider).toBe('antigravity') | ||
| expect(antigravity.status).toBe('ok') | ||
| expect(antigravity.session?.usedPercent).toBe(42) | ||
| expect(antigravity.error).toBeNull() | ||
| }) | ||
|
|
||
| it('reports unavailable without quoting the Gemini failure', () => { | ||
| const antigravity = deriveAntigravityRateLimits( | ||
| geminiSnapshot('error', 'Gemini project ID not found') | ||
| ) | ||
|
|
||
| expect(antigravity.provider).toBe('antigravity') | ||
| expect(antigravity.status).toBe('unavailable') | ||
| expect(antigravity.error).not.toContain('Gemini project ID not found') | ||
| expect(antigravity.error).toContain('Antigravity usage is not available') | ||
| expect(antigravity.session).toBeNull() | ||
| expect(antigravity.weekly).toBeNull() | ||
| }) | ||
|
|
||
| it('does not blame a missing sign-in when the quota read itself failed', () => { | ||
| const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) | ||
|
|
||
| // Why: the reported symptom is a connected sign-in whose Code Assist read failed. | ||
| expect(antigravity.error).toContain('could not be read right now') | ||
| expect(antigravity.error).not.toContain('sign-in is connected') | ||
| }) | ||
|
|
||
| it('keeps the Gemini timestamp so activation freshness checks are not forced to refetch', () => { | ||
| const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) | ||
|
|
||
| expect(antigravity.updatedAt).toBe(1_700_000_000_000) | ||
| }) | ||
|
|
||
| it('points at the missing sign-in when the Gemini opt-in is off', () => { | ||
| const antigravity = deriveAntigravityRateLimits( | ||
| geminiSnapshot('unavailable', 'Gemini CLI OAuth is disabled in settings') | ||
| ) | ||
|
|
||
| expect(antigravity.status).toBe('unavailable') | ||
| expect(antigravity.error).not.toContain('Gemini CLI OAuth is disabled in settings') | ||
| expect(antigravity.error).toContain('Antigravity usage is not available') | ||
| expect(antigravity.error).toContain('Gemini CLI sign-in is connected') | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import type { ProviderRateLimits } from '../../shared/rate-limit-types' | ||
|
|
||
| // Why: the Antigravity CLI keeps its token in the OS keyring, not in the files the Gemini | ||
| // fetcher reads, so Orca never actually queries Antigravity. Only a *successful* Gemini read | ||
| // describes shared Google Code Assist quota; republishing a Gemini failure under the | ||
| // Antigravity provider id surfaced "Refresh failed" for a request that was never attempted. | ||
| const ANTIGRAVITY_NO_SIGN_IN_REASON = | ||
| 'Antigravity usage is not available. Orca can only show shared Google Code Assist quota while a Gemini CLI sign-in is connected.' | ||
| // Why: a Gemini `error` means the sign-in exists and the quota read failed, so blaming a missing sign-in would misdirect the user. | ||
| const ANTIGRAVITY_QUOTA_UNREADABLE_REASON = | ||
| 'Antigravity usage is not available. Orca reads it from the shared Google Code Assist quota, which could not be read right now.' | ||
|
|
||
| export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits { | ||
| if (gemini.status === 'ok') { | ||
| return { ...gemini, provider: 'antigravity' } | ||
| } | ||
| return { | ||
| provider: 'antigravity', | ||
| session: null, | ||
| weekly: null, | ||
| // Why: reuse the Gemini timestamp so activation freshness checks don't force a refetch every cycle. | ||
| updatedAt: gemini.updatedAt, | ||
| error: | ||
| gemini.status === 'unavailable' | ||
| ? ANTIGRAVITY_NO_SIGN_IN_REASON | ||
| : ANTIGRAVITY_QUOTA_UNREADABLE_REASON, | ||
| status: 'unavailable' | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | ||
| import { RateLimitService } from './service' | ||
| import { fetchClaudeRateLimits } from './claude-fetcher' | ||
| import { fetchCodexRateLimits } from './codex-fetcher' | ||
| import { fetchGeminiRateLimits } from './gemini-usage-fetcher' | ||
| import { | ||
| errorProvider, | ||
| okProvider, | ||
| resetRateLimitProviderMocks | ||
| } from './rate-limit-service-test-harness' | ||
|
|
||
| vi.mock('./claude-fetcher', () => ({ | ||
| fetchClaudeRateLimits: vi.fn(), | ||
| fetchManagedAccountUsage: vi.fn() | ||
| })) | ||
|
|
||
| vi.mock('./codex-fetcher', () => ({ | ||
| consumeCodexRateLimitResetCredit: vi.fn(), | ||
| fetchCodexRateLimits: vi.fn() | ||
| })) | ||
|
|
||
| vi.mock('./gemini-usage-fetcher', () => ({ | ||
| fetchGeminiRateLimits: vi.fn() | ||
| })) | ||
|
|
||
| vi.mock('./kimi-fetcher', () => ({ | ||
| fetchKimiRateLimits: vi.fn() | ||
| })) | ||
|
|
||
| vi.mock('./opencode-go-usage-fetcher', () => ({ | ||
| fetchOpenCodeGoRateLimits: vi.fn() | ||
| })) | ||
|
|
||
| vi.mock('./minimax-fetcher', () => ({ | ||
| fetchMiniMaxRateLimits: vi.fn() | ||
| })) | ||
|
|
||
| vi.mock('./grok-fetcher', () => ({ | ||
| fetchGrokRateLimits: vi.fn() | ||
| })) | ||
|
|
||
| vi.mock('./grok-auth', () => ({ | ||
| readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) | ||
| })) | ||
|
|
||
| vi.mock('../minimax/minimax-cookie-store', () => ({ | ||
| hasMiniMaxSessionCookie: vi.fn(() => false) | ||
| })) | ||
|
|
||
| describe('RateLimitService Antigravity usage', () => { | ||
| beforeEach(() => { | ||
| resetRateLimitProviderMocks() | ||
| vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) | ||
| vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) | ||
| }) | ||
|
|
||
| it('does not republish a Gemini failure as an Antigravity refresh failure', async () => { | ||
| vi.mocked(fetchGeminiRateLimits).mockResolvedValue( | ||
| errorProvider('gemini', 'Gemini project ID not found') | ||
| ) | ||
| const service = new RateLimitService() | ||
|
|
||
| await service.refresh() | ||
|
|
||
| const state = service.getState() | ||
| expect(state.antigravity?.status).toBe('unavailable') | ||
| expect(state.antigravity?.error).not.toContain('Gemini project ID not found') | ||
| expect(state.antigravity?.session).toBeNull() | ||
| // Why: the real Gemini failure must still surface under its own provider. | ||
| expect(state.gemini?.status).toBe('error') | ||
| expect(state.gemini?.error).toBe('Gemini project ID not found') | ||
| }) | ||
|
|
||
| it('keeps mirroring a successful Gemini read under the Antigravity provider', async () => { | ||
| vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) | ||
| const service = new RateLimitService() | ||
|
|
||
| await service.refresh() | ||
|
|
||
| const state = service.getState() | ||
| expect(state.antigravity?.status).toBe('ok') | ||
| expect(state.antigravity?.provider).toBe('antigravity') | ||
| expect(state.antigravity?.session?.usedPercent).toBe(42) | ||
| }) | ||
|
|
||
| it('never leaves a cached Antigravity snapshot in the error retry lane', async () => { | ||
| vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now())) | ||
| const service = new RateLimitService() | ||
| await service.refresh() | ||
|
|
||
| vi.mocked(fetchGeminiRateLimits).mockResolvedValue( | ||
| errorProvider('gemini', 'Token refresh failed') | ||
| ) | ||
| await service.refresh() | ||
|
|
||
| // Why: stale-retention would otherwise show Gemini numbers as "Refresh failed" Antigravity usage. | ||
| expect(service.getState().antigravity?.status).toBe('unavailable') | ||
| expect(service.getState().antigravity?.session).toBeNull() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Do not copy Gemini-only buckets into Antigravity state.
Line 15 copies
buckets, butProviderRateLimitsdefines them as Gemini-only. Construct the Antigravity result from shared quota fields. Add a regression test with Gemini buckets and assert that Antigravity has no buckets.Proposed fix
if (gemini.status === 'ok') { - return { ...gemini, provider: 'antigravity' } + return { + provider: 'antigravity', + session: gemini.session, + weekly: gemini.weekly, + updatedAt: gemini.updatedAt, + error: null, + status: 'ok', + usageMetadata: gemini.usageMetadata + } }📝 Committable suggestion