mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +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,117 @@
|
||||
/**
|
||||
* @fileoverview GitHub Copilot's monthly premium-request quota, for the header plan-usage chip.
|
||||
*
|
||||
* The Copilot CLI has no scriptable usage command (`copilot billing` exists only inside the TUI), but
|
||||
* the same quota snapshot the editors show is `GET https://api.github.com/copilot_internal/user`,
|
||||
* readable with the CLI's own sign-in token. The endpoint is not part of GitHub's documented REST API,
|
||||
* so this is read-only, best-effort and silent: any failure (offline, 401, a changed shape) yields
|
||||
* `null` and the chip simply has no Copilot row.
|
||||
*
|
||||
* ⚠️ The token is never logged, never put in an error and only ever sent to api.github.com over HTTPS
|
||||
* (`redirect: 'error'`, so a redirect cannot carry it elsewhere).
|
||||
*
|
||||
* @module utils/copilot-plan-usage
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parseCopilotQuotaResponse, type StatusTelemetry } from '../usage-telemetry.js';
|
||||
|
||||
export const COPILOT_QUOTA_URL = 'https://api.github.com/copilot_internal/user';
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Env names Copilot itself reads for a headless sign-in, in its own precedence order. */
|
||||
const TOKEN_ENV_KEYS = ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'] as const;
|
||||
|
||||
/** The CLI writes `// ...` header comments above the JSON in config.json. */
|
||||
function parseCommentedJson(raw: string): unknown {
|
||||
return JSON.parse(
|
||||
raw
|
||||
.split('\n')
|
||||
.filter((line) => !line.trimStart().startsWith('//'))
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sign-in token Copilot would use: a token in the environment first, then the plain-text
|
||||
* `authTokens` entry the CLI keeps in `<COPILOT_HOME or ~/.copilot>/config.json` when no OS keychain
|
||||
* is available. Only github.com sign-ins are read from the file (the quota URL is github.com's).
|
||||
*/
|
||||
export async function resolveCopilotToken(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
home: string = homedir()
|
||||
): Promise<string | null> {
|
||||
for (const key of TOKEN_ENV_KEYS) {
|
||||
const value = env[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
const dir = env.COPILOT_HOME?.trim() || join(home, '.copilot');
|
||||
try {
|
||||
const config = parseCommentedJson(await readFile(join(dir, 'config.json'), 'utf8')) as {
|
||||
lastLoggedInUser?: { host?: string; login?: string };
|
||||
authTokens?: Record<string, { token?: unknown } | undefined>;
|
||||
};
|
||||
const { host, login } = config.lastLoggedInUser ?? {};
|
||||
if (host !== 'https://github.com' || !login || !config.authTokens) return null;
|
||||
for (const key of [`${host}:${login}`, `${host}:${login}:github`]) {
|
||||
const token = config.authTokens[key]?.token;
|
||||
if (typeof token === 'string' && token.trim()) return token.trim();
|
||||
}
|
||||
} catch {
|
||||
/* no config, unreadable, or not JSON: no token */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ReadCopilotPlanUsageOptions {
|
||||
/** Defaults to `resolveCopilotToken()`. */
|
||||
token?: string | null;
|
||||
/** Injectable for tests. */
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
/** The monthly premium-request window, or null when there is no token, no meter or any failure. */
|
||||
export async function readCopilotPlanUsage(opts: ReadCopilotPlanUsageOptions = {}): Promise<StatusTelemetry | null> {
|
||||
const token = opts.token === undefined ? await resolveCopilotToken() : opts.token;
|
||||
if (!token) return null;
|
||||
const doFetch = opts.fetchFn ?? fetch;
|
||||
try {
|
||||
const response = await doFetch(COPILOT_QUOTA_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'codeman',
|
||||
},
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return parseCopilotQuotaResponse(await response.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CopilotPlanUsageGates {
|
||||
/** `showPlanUsageLimits` as the server reads it (`readPlanUsageTelemetryEnabled()`: absent = on). */
|
||||
planUsageEnabled: () => Promise<boolean>;
|
||||
/** `resolveCopilotToken()`: env vars and a file read, nothing spawned. */
|
||||
resolveToken: () => Promise<string | null>;
|
||||
/** `isCopilotAvailable()`: its resolver may run a synchronous login-shell probe. */
|
||||
copilotInstalled: () => boolean;
|
||||
read: (token: string) => Promise<StatusTelemetry | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's poll: Copilot's quota, or null when Plan Usage Limits is off, there is no sign-in
|
||||
* token or the CLI is not installed. Cheapest gate first, so a host with no Copilot sign-in never
|
||||
* runs the CLI resolver on each tick, and nothing at all is read while Plan Usage Limits is off.
|
||||
*/
|
||||
export async function readCopilotPlanUsageIfEnabled(gates: CopilotPlanUsageGates): Promise<StatusTelemetry | null> {
|
||||
if (!(await gates.planUsageEnabled())) return null;
|
||||
const token = await gates.resolveToken();
|
||||
if (!token || !gates.copilotInstalled()) return null;
|
||||
return gates.read(token);
|
||||
}
|
||||
@@ -76,6 +76,7 @@ export {
|
||||
getCopilotNotFoundMessage,
|
||||
getCopilotCliVersion,
|
||||
} from './copilot-cli-resolver.js';
|
||||
export { readCopilotPlanUsage, resolveCopilotToken } from './copilot-plan-usage.js';
|
||||
export {
|
||||
boundedPathExists,
|
||||
describeUnknownPath,
|
||||
|
||||
Reference in new issue
Block a user