mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
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>
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
/**
|
|
* @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);
|
|
});
|
|
});
|