Repository navigation
Add copilot-relay usage: Copilot plan and quota - #156
Merged
Merged
Conversation
copilot-relay usage [--json] prints the Copilot plan, SKU and quota reset date, then one line per quota, from GitHub's copilot_internal/user. It requests it with the stored GitHub token, so no relay needs to run. It exchanges no Copilot token and writes no file, the log included. - src/lib/auth.ts: getCopilotUsage sends the request with githubHeaders and the same fetch as the other GitHub calls in that file, so the shared proxy dispatcher for #153 can move it with them. readStoredGitHubToken reads the token without creating a directory or file and without migrating a legacy token. - src/lib/usage.ts: checks every field on its own, keeps only the plan and quota fields, makes each string terminal-safe, renders the report, and turns each failure into one line that never holds the token or the request headers. - src/usage.ts, registered in src/main.ts: prints to stdout and stderr and sets process.exitCode = 1 instead of throwing, because citty prints a thrown error with consola.error, which also writes the log. - src/lib/config.ts: exports vscodeVersion, so the request sends the editor version without readAppConfig, which can write config.yaml. The report shows GitHub's values as they are and computes nothing new. The --json form prints a curated object, never the raw response, which carries identifying fields such as the login, organization lists and tracking ids. Tests mock fetch and never call GitHub. The README, both Logging-Troubleshooting pages and both Architecture pages describe the command. Closes #154 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
terminalText makes the strings in GitHub's answer terminal-safe but does not redact them, and only the network-error line was checked for the token. An HTTP 200 answer that echoed the token in copilot_plan, access_type_sku, quota_reset_date or a quota id was printed by both the report and --json. loadCopilotUsage now searches every kept string and quota id for the token after terminalText has run. It refuses such an answer with one fixed line instead of printing any of it, because a redacted plan or quota id would print as if it were data. The network-error line is now made terminal-safe before the token is redacted from it, so a hidden character inside a quoted token cannot keep it out of the match. A token with no visible character cannot show in printed text, and is not searched for: every text contains its empty visible form. Unit tests cover an echo in each kept string, in a quota id and behind a hidden character, a quoted token split by a hidden character, and a token with no visible character. Integration tests cover the report and the --json output. Both Logging-Troubleshooting pages list the new failure line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The --json paragraph said that every quota id maps to an object with every snapshot key. parseSnapshot makes a snapshot that is absent, or is not a JSON object, null. Both Logging-Troubleshooting pages now say that each known quota id is always present, that an absent or mistyped snapshot is null, and that a snapshot object always has all eight keys, with a missing or mistyped field null. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Some strings and one regular expression in tests/unit/usage.test.ts held a literal zero-width space or right-to-left override. They are now written as Unicode escapes, which compile to the same characters. An invisible character in source does not survive an editor, a diff view or a paste reliably; src/lib/redact.ts writes its patterns with escapes for the same reason. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
9 of 11 tasks
showsToken compared each printed string with the token exactly, and a failure line had only exact copies of the token redacted. An answer with a space inside the token, or with the token spread over copilot_plan and access_type_sku, was printed by the report and by --json, and a network error's reason that quoted the token with a space inside it was printed as it was. Both checks now also compare letters and digits alone: every other character is removed from the token as a terminal shows it, and from the text. showsToken searches the plan, SKU, reset date and quota ids, joined in the order --json prints them, and refuses a match with the existing fixed line. A failure line that still spells the token after the exact replacement prints "Could not reach GitHub." without its reason. The comparison applies only to a token with at least 16 letters and digits, so ordinary text cannot match a short one by chance. The exact checks are kept, and an accepted answer is printed unchanged. The detection is bounded to separators inside the token and a span across the printed strings; a token written any other way, such as in another case or encoding, is not found. Unit tests cover a space inside a field, the token spread over two fields, a network error's reason with a space inside the token, a stored token with a character terminalText removes, an answer that does not show the token, and a short token, which is matched only as an exact copy. Integration tests cover the spread token in the report and in --json. The test oracles also compare letters and digits alone. Both Logging-Troubleshooting pages describe the check and the new line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The short-token test stored a token with fewer than 16 letters and digits that the fixture's text spells only by chance. It still passed with the threshold raised from 16 to 17, and no test refused a short token by its exact copy. Three tests now sit at the threshold: a token with exactly 16 letters and digits is found with a space inside it; a token with exactly 15 is not, so that answer prints unchanged; and an exact copy of the token with 15 is still refused, which only the exact check can do. Raising the threshold to 17, lowering it to 15, or removing the exact check each fails one of them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
D0n9X1n
added a commit
that referenced
this pull request
Oct 3, 2026
getCopilotUsage (#156) called the global fetch, so copilot-relay usage ignored upstreamProxy, and behind a proxy it could not reach GitHub. It now goes through fetchUpstream, like every other GitHub call. usage writes no file, so it cannot use readAppConfig, which creates or completes config.yaml. readExistingAppConfig resolves an existing config.yaml without rewriting it and returns undefined when there is none, so the request then connects directly. A config.yaml that cannot be read or is invalid gets one fixed line that names the path and repeats nothing from the file. A malformed proxy variable with upstreamProxy: env gets the fixed message of the new InvalidProxyEnvironmentError. usage then builds its dispatcher with configureUpstreamDispatcher before the request, and the whole command runs under withoutLogging, so a line the request would log, such as the ignored-proxy hint, never reaches the log file. The token guard is unchanged. The usage tests stubbed GitHub on the global fetch, which no longer intercepts the request, so they would have reached api.github.com. They now use the network fixture: a local stand-in behind redirectGitHubTo, and refuseExternalConnections in the test process and in each CLI child. Every assertion is kept, the #156 threshold tests included, and so are the checks that usage makes no token exchange and no /user call. A failure the old stub threw is now a dispatch that fails before it is sent, and the timeout test lets the stand-in leave the request unanswered with the deadline shortened to 50 ms. The docs cover the usage route, its read-only config read and its two new failure lines, and the README no longer says the request goes to GitHub directly. An Internals note explains why a stand-in started with a top-level await must come before the first test(): node:test runs the after() hooks once every test registered so far has finished, and one registered later never runs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
Adds
copilot-relay usage [--json](#154): the Copilot plan, SKU and quota reset date, then one line per quota, from GitHub'scopilot_internal/user, requested with the stored GitHub token. No relay needs to run. It exchanges no Copilot token and writes no file, the log included.src/lib/auth.ts:getCopilotUsagesends the request withgithubHeadersand the samefetchas the other GitHub calls in that file, so the [Feature]: Send Copilot and GitHub calls through an outbound proxy (upstreamProxy) #153 dispatcher change can move it with them.readStoredGitHubTokenreads the token without creating a directory or file and without migrating a legacy token.src/lib/usage.ts: checks every field on its own (a missing or mistyped field counts as not reported), keeps only the plan and quota fields, makes each string terminal-safe, renders the report, and turns each failure into one line. It refuses an answer whose plan, SKU, reset date or quota ids would show the stored token: written exactly, with other characters inside it, or spread over those strings.src/usage.ts, registered insrc/main.ts: prints to stdout and stderr and setsprocess.exitCode = 1instead of throwing, because citty prints a thrown error withconsola.error, which also writes the log file.src/lib/config.ts: exportsvscodeVersion, so the request sends the editor version withoutreadAppConfig, which can writeconfig.yaml.Text output, with placeholder values:
A quota with
credits_usedgets; credits used <n>at the end. Values print as GitHub sent them; nothing is rounded or computed. A missing field prints?; a missing plan, SKU, reset date or quota printsnot reported.--jsonprints a curated object with the same fields, never the raw response.copilot_plan,access_type_sku,quota_reset_dateand each known quota id are always present; a value, snapshot or field that is absent or mistyped isnull; a snapshot object always has all eight keys.Closes #154
Scope
A CLI command only: no HTTP route, no config key, and no change to the relay's request path.
Validation
npm run typechecknpm run test:unit: 986 tests, 956 pass, 30 skipped (none new), 0 failnpm run test:integration: 402 tests, 402 pass, 0 failnpm run buildRun locally on Windows with Node 24.19.0. New tests:
tests/unit/usage.test.ts(43) andtests/integration/usage-command.test.ts(9). Both mockfetch; nothing calls GitHub. The integration test runs the CLI in a child process against a temporary home and checks that nothing in it is created or changed.Notes
~/.copilot-relay/github_tokenand sent only in theauthorizationheader. GitHub's answer and a network error's reason are checked for it before anything is printed: written exactly, with other characters between its letters and digits, or, in an answer, spread over the plan, SKU, reset date and quota ids. An exact copy in a reason prints as[redacted]; a reason that still spells the token printsCould not reach GitHub.; an answer that would show it is refused with one fixed line, and none of it is printed. The two looser forms are searched for only when the token has at least 16 letters and digits. A token written any other way, such as in another case or encoding, is not found.Checklist
config.default.yaml, README, andwiki/(EN and ZH). No config change; the command is documented in the README and in both wiki languages.After merge
publish-wiki.ymlsucceeded on the merge SHA, then click through the new sections on the live EN and 中文 tabs.🤖 Generated with Claude Code