mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
feat(copilot): plan-usage chip row for the monthly premium-request quota (#662)
Squash of PR #662. The header plan-usage chip gains a Copilot row: the calendar-month premium-request quota, with the request counts and the reset in the tooltip, read every 10 minutes from GitHub's copilot_internal/user endpoint with the Copilot CLI's own sign-in token. It rides showPlanUsageLimits and is on by default while the CLI is installed and signed in; any failure leaves the chip without the row. Merge-time fixes by the maintainer: the gates (setting, then token, then the CLI probe) live in readCopilotPlanUsageIfEnabled() so a host with no Copilot sign-in never runs the login-shell probe each tick, with a test that Plan Usage Limits off means no request; the X-GitHub-Api-Version header the Copilot CLI itself does not send is dropped; and CLAUDE.md, architecture-invariants, README and copilot-integration describe the read. The hooks-config import in server.ts was merged with #666's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f80f107445
commit
34cf798b10
22 files changed
+642
-26
No files matched your search
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @fileoverview GitHub Copilot's monthly premium-request quota for the shared plan-usage chip:
|
||||
* the quota parser, the sign-in token lookup and the read-only HTTP reader.
|
||||
*
|
||||
* The fixture is the shape `GET /copilot_internal/user` returned for a Business seat (values rounded);
|
||||
* `chat` and `completions` are `unlimited`, only `premium_interactions` is metered.
|
||||
*/
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { parseCopilotQuotaResponse, telemetrySignature } from '../src/usage-telemetry.js';
|
||||
import {
|
||||
COPILOT_QUOTA_URL,
|
||||
readCopilotPlanUsage,
|
||||
readCopilotPlanUsageIfEnabled,
|
||||
resolveCopilotToken,
|
||||
} from '../src/utils/copilot-plan-usage.js';
|
||||
|
||||
const RESPONSE = {
|
||||
copilot_plan: 'business',
|
||||
quota_reset_date_utc: '2026-11-01T00:00:00.000Z',
|
||||
quota_reset_date: '2026-11-01',
|
||||
quota_snapshots: {
|
||||
chat: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true },
|
||||
completions: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true },
|
||||
premium_interactions: {
|
||||
entitlement: 5000,
|
||||
remaining: 3785,
|
||||
percent_remaining: 75.7,
|
||||
unlimited: false,
|
||||
overage_permitted: true,
|
||||
overage_count: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('parseCopilotQuotaResponse', () => {
|
||||
it('maps premium_interactions to a monthly window with counts and the reset instant', () => {
|
||||
expect(parseCopilotQuotaResponse(RESPONSE)).toEqual({
|
||||
monthly: {
|
||||
usedPercentage: expect.closeTo(24.3, 5),
|
||||
resetAt: Date.parse('2026-11-01T00:00:00.000Z'),
|
||||
used: 1215,
|
||||
limit: 5000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores the unlimited chat and completions buckets entirely', () => {
|
||||
const only = { quota_snapshots: { chat: RESPONSE.quota_snapshots.chat } };
|
||||
expect(parseCopilotQuotaResponse(only)).toBeNull();
|
||||
});
|
||||
|
||||
it('has nothing to meter for an unlimited premium quota', () => {
|
||||
const unlimited = {
|
||||
quota_snapshots: {
|
||||
premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true },
|
||||
},
|
||||
};
|
||||
expect(parseCopilotQuotaResponse(unlimited)).toBeNull();
|
||||
});
|
||||
|
||||
it('derives the percent from the counts when percent_remaining is missing', () => {
|
||||
const result = parseCopilotQuotaResponse({
|
||||
quota_snapshots: { premium_interactions: { entitlement: 300, remaining: 75 } },
|
||||
quota_reset_date: '2026-11-01',
|
||||
});
|
||||
expect(result?.monthly?.usedPercentage).toBe(75);
|
||||
expect(result?.monthly?.used).toBe(225);
|
||||
});
|
||||
|
||||
it('clamps overage to 100% and keeps the real count', () => {
|
||||
const result = parseCopilotQuotaResponse({
|
||||
quota_snapshots: { premium_interactions: { entitlement: 300, remaining: -40, percent_remaining: -13.3 } },
|
||||
});
|
||||
expect(result?.monthly?.usedPercentage).toBe(100);
|
||||
expect(result?.monthly?.used).toBe(340);
|
||||
});
|
||||
|
||||
it('rejects anything it cannot read as a quota', () => {
|
||||
for (const bad of [
|
||||
null,
|
||||
undefined,
|
||||
'x',
|
||||
3,
|
||||
{},
|
||||
{ quota_snapshots: {} },
|
||||
{ quota_snapshots: { premium_interactions: 'x' } },
|
||||
]) {
|
||||
expect(parseCopilotQuotaResponse(bad)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns nothing for a zero quota that is not unlimited', () => {
|
||||
expect(
|
||||
parseCopilotQuotaResponse({
|
||||
quota_snapshots: { premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 0 } },
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('changes the signature when only the limit changes (a plan change mid-month)', () => {
|
||||
// 100 requests used and the same reported percentage under both plans: only the entitlement differs.
|
||||
const quota = (entitlement: number) =>
|
||||
parseCopilotQuotaResponse({
|
||||
quota_snapshots: {
|
||||
premium_interactions: { entitlement, remaining: entitlement - 100, percent_remaining: 66.7 },
|
||||
},
|
||||
quota_reset_date_utc: '2026-11-01T00:00:00.000Z',
|
||||
})!;
|
||||
expect(telemetrySignature(quota(300))).not.toBe(telemetrySignature(quota(1500)));
|
||||
});
|
||||
|
||||
it('feeds the change-detection signature without disturbing the Claude/Codex one', () => {
|
||||
const copilot = parseCopilotQuotaResponse(RESPONSE)!;
|
||||
expect(telemetrySignature(copilot)).not.toBe(telemetrySignature({}));
|
||||
// A Claude-only payload has exactly the four-element signature it always had.
|
||||
expect(JSON.parse(telemetrySignature({ fiveHour: { usedPercentage: 10, resetAt: 1 } }))).toEqual([
|
||||
10,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCopilotToken', () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
const home = (config?: string) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'copilot-token-'));
|
||||
dirs.push(dir);
|
||||
if (config !== undefined) {
|
||||
mkdirSync(join(dir, '.copilot'));
|
||||
writeFileSync(join(dir, '.copilot', 'config.json'), config);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
const CONFIG = [
|
||||
'// User settings belong in settings.json.',
|
||||
JSON.stringify({
|
||||
lastLoggedInUser: { host: 'https://github.com', login: 'octo' },
|
||||
authTokens: {
|
||||
'https://github.com:octo': { token: 'file-token' },
|
||||
'https://github.com:other': { token: 'other-token' },
|
||||
},
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
it('prefers the environment, in the order Copilot itself reads it', async () => {
|
||||
const h = home(CONFIG);
|
||||
await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c', GH_TOKEN: 'b', COPILOT_GITHUB_TOKEN: 'a' }, h)).resolves.toBe(
|
||||
'a'
|
||||
);
|
||||
await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c', GH_TOKEN: 'b' }, h)).resolves.toBe('b');
|
||||
await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c' }, h)).resolves.toBe('c');
|
||||
});
|
||||
|
||||
it("falls back to the signed-in user's token in the commented config.json", async () => {
|
||||
await expect(resolveCopilotToken({}, home(CONFIG))).resolves.toBe('file-token');
|
||||
});
|
||||
|
||||
it('honours COPILOT_HOME', async () => {
|
||||
const elsewhere = home();
|
||||
mkdirSync(join(elsewhere, 'cp'));
|
||||
writeFileSync(join(elsewhere, 'cp', 'config.json'), CONFIG.replace('file-token', 'relocated'));
|
||||
await expect(resolveCopilotToken({ COPILOT_HOME: join(elsewhere, 'cp') }, home())).resolves.toBe('relocated');
|
||||
});
|
||||
|
||||
it('reads no file token for another host, a missing file or a broken one', async () => {
|
||||
const ghe = CONFIG.replace('"host":"https://github.com"', '"host":"https://ghe.example.com"');
|
||||
await expect(resolveCopilotToken({}, home(ghe))).resolves.toBeNull();
|
||||
await expect(resolveCopilotToken({}, home())).resolves.toBeNull();
|
||||
await expect(resolveCopilotToken({}, home('{ not json'))).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readCopilotPlanUsage', () => {
|
||||
const ok = (body: unknown, status = 200) =>
|
||||
vi.fn(async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch;
|
||||
|
||||
it('asks api.github.com with the bearer token, no redirects, and normalizes the answer', async () => {
|
||||
const fetchFn = ok(RESPONSE);
|
||||
const usage = await readCopilotPlanUsage({ token: 'tok', fetchFn });
|
||||
expect(usage?.monthly?.limit).toBe(5000);
|
||||
const [url, init] = (fetchFn as unknown as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(COPILOT_QUOTA_URL);
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok');
|
||||
expect(init.redirect).toBe('error');
|
||||
});
|
||||
|
||||
it('resolves the token itself when none is passed, and sends it in the header', async () => {
|
||||
vi.stubEnv('COPILOT_GITHUB_TOKEN', 'env-token');
|
||||
try {
|
||||
const fetchFn = ok(RESPONSE);
|
||||
expect(await readCopilotPlanUsage({ fetchFn })).not.toBeNull();
|
||||
const init = (fetchFn as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1] as {
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
expect(init.headers.Authorization).toBe('Bearer env-token');
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends nothing when there is no token', async () => {
|
||||
const fetchFn = ok(RESPONSE);
|
||||
expect(await readCopilotPlanUsage({ token: null, fetchFn })).toBeNull();
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers null, never throws, on an HTTP error, a network failure or a changed shape', async () => {
|
||||
expect(await readCopilotPlanUsage({ token: 't', fetchFn: ok({ message: 'Bad credentials' }, 401) })).toBeNull();
|
||||
expect(await readCopilotPlanUsage({ token: 't', fetchFn: ok({ unexpected: true }) })).toBeNull();
|
||||
const down = vi.fn(async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
}) as unknown as typeof fetch;
|
||||
expect(await readCopilotPlanUsage({ token: 't', fetchFn: down })).toBeNull();
|
||||
});
|
||||
|
||||
it('never puts the token in anything it returns', async () => {
|
||||
const usage = await readCopilotPlanUsage({ token: 'super-secret-token', fetchFn: ok(RESPONSE) });
|
||||
expect(JSON.stringify(usage)).not.toContain('super-secret-token');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The server's 10-minute poll goes through these gates in this order: Plan Usage Limits on, then a
|
||||
* sign-in token, then the CLI installed, so a host with no Copilot sign-in never runs the CLI
|
||||
* resolver (whose last step is a login-shell probe) on every tick.
|
||||
*/
|
||||
describe('readCopilotPlanUsageIfEnabled', () => {
|
||||
const gates = (over: Partial<Parameters<typeof readCopilotPlanUsageIfEnabled>[0]> = {}) => {
|
||||
const fetchFn = vi.fn(
|
||||
async () => new Response(JSON.stringify(RESPONSE), { status: 200 })
|
||||
) as unknown as typeof fetch;
|
||||
const deps = {
|
||||
planUsageEnabled: vi.fn(async () => true),
|
||||
resolveToken: vi.fn(async (): Promise<string | null> => 'tok'),
|
||||
copilotInstalled: vi.fn(() => true),
|
||||
read: vi.fn((token: string) => readCopilotPlanUsage({ token, fetchFn })),
|
||||
...over,
|
||||
};
|
||||
return { deps, fetchFn };
|
||||
};
|
||||
|
||||
it('sends no request, and looks for no token or CLI, while Plan Usage Limits is off', async () => {
|
||||
const { deps, fetchFn } = gates({ planUsageEnabled: vi.fn(async () => false) });
|
||||
expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull();
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
expect(deps.read).not.toHaveBeenCalled();
|
||||
expect(deps.resolveToken).not.toHaveBeenCalled();
|
||||
expect(deps.copilotInstalled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never probes for the CLI on a host with no Copilot sign-in', async () => {
|
||||
const { deps, fetchFn } = gates({ resolveToken: vi.fn(async () => null) });
|
||||
expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull();
|
||||
expect(deps.copilotInstalled).not.toHaveBeenCalled();
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends no request when the CLI is not installed, whatever token the environment holds', async () => {
|
||||
const { deps, fetchFn } = gates({ copilotInstalled: vi.fn(() => false) });
|
||||
expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull();
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the quota with the resolved token once every gate passes', async () => {
|
||||
const { deps, fetchFn } = gates();
|
||||
expect((await readCopilotPlanUsageIfEnabled(deps))?.monthly?.limit).toBe(5000);
|
||||
expect(deps.read).toHaveBeenCalledWith('tok');
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -139,4 +139,44 @@ describe('header plan usage chip', () => {
|
||||
expect(chip.innerHTML).toContain('12%');
|
||||
expect(chip.title).toContain('Codex plan usage');
|
||||
});
|
||||
|
||||
it('shows Copilot as a monthly window with request counts in the tooltip', () => {
|
||||
const { CodemanApp, chip } = loadCodemanAppClass();
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
|
||||
|
||||
app.updatePlanUsageChip({
|
||||
fiveHour: { usedPercentage: 10, resetAt: 1000 },
|
||||
sevenDay: { usedPercentage: 20, resetAt: 2000 },
|
||||
copilot: { monthly: { usedPercentage: 24.3, resetAt: 3000, used: 1215, limit: 5000 } },
|
||||
});
|
||||
|
||||
expect(chip.innerHTML).toContain('class="pu-provider">Copilot</span>');
|
||||
expect(chip.innerHTML).toContain('mo');
|
||||
expect(chip.innerHTML).toContain('24%');
|
||||
expect(chip.title).toContain('Copilot plan usage');
|
||||
expect(chip.title).toContain('1215 of 5000 requests');
|
||||
// Only Copilot's own row carries a monthly window.
|
||||
const claudeRow = chip.innerHTML.slice(0, chip.innerHTML.indexOf('Copilot'));
|
||||
expect(claudeRow).not.toContain('>mo<');
|
||||
});
|
||||
|
||||
it('shows a lone Copilot row without a provider label or a Claude placeholder', () => {
|
||||
const { CodemanApp, chip } = loadCodemanAppClass();
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
|
||||
|
||||
app.updatePlanUsageChip({ copilot: { monthly: { usedPercentage: 80, resetAt: 3000 } } });
|
||||
|
||||
expect(chip.innerHTML).toContain('80%');
|
||||
expect(chip.innerHTML).not.toContain('pu-provider');
|
||||
expect(chip.innerHTML).not.toContain('pu-win-idle');
|
||||
});
|
||||
|
||||
it('renders no Copilot row when the provider reports nothing', () => {
|
||||
const { CodemanApp, chip } = loadCodemanAppClass();
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
|
||||
|
||||
app.updatePlanUsageChip({ sevenDay: { usedPercentage: 30, resetAt: 2000 }, copilot: null });
|
||||
|
||||
expect(chip.innerHTML).not.toContain('Copilot');
|
||||
});
|
||||
});
|
||||
@@ -16,3 +16,16 @@ it('preserves the Codex row when a later Claude status-line sample arrives', ()
|
||||
codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the Copilot row when a later Claude status-line sample arrives', () => {
|
||||
const module = latestModule as Record<string, unknown>;
|
||||
const setCopilot = module.setLatestCopilotPlanUsage as (value: unknown) => unknown;
|
||||
const setClaude = module.setLatestPlanUsage as (value: Record<string, unknown>) => unknown;
|
||||
|
||||
setCopilot({ monthly: { usedPercentage: 12, resetAt: 5000, used: 36, limit: 300 } });
|
||||
expect(setClaude({ sessionId: 's2', fiveHour: { usedPercentage: 50, resetAt: 2000 } })).toMatchObject({
|
||||
sessionId: 's2',
|
||||
fiveHour: { usedPercentage: 50, resetAt: 2000 },
|
||||
copilot: { monthly: { usedPercentage: 12, resetAt: 5000, used: 36, limit: 300 } },
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user