feat: multi-account auth management (add/list/remove)#33
Merged
Conversation
Introduce bun:sqlite-backed db module with WAL mode, foreign keys, and a meta-table-driven migration runner. Initial schema (001) creates accounts, model_pricing, model_pricing_versions, pricing_sync_log, usage_events, usage_daily and supporting indexes. - src/lib/db.ts: initDb/getDb/withTransaction + test-only reset helper - src/lib/migrations/001_initial.sql: full schema per design doc 02 - src/lib/paths.ts: USAGE_DB_PATH under APP_DIR - src/start.ts: --db-path flag, initDb after ensurePaths - src/types.d.ts: declare *.sql text imports - tests/db.test.ts: 8 tests covering init, idempotency, tx, WAL, indexes Closes #1 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
- src/lib/account-pool.ts: AccountPool with round-robin / least-busy / least-recent strategies, acquire/release, cooldown and failure tracking - src/lib/accounts-loader.ts: load accounts.json or fall back to legacy single token; persistAccounts() upserts into accounts table - src/lib/state.ts: pool + strategy on State (legacy githubToken/copilotToken kept as shims for not-yet-migrated callers; removed in #3) - src/lib/token.ts: setupCopilotTokenFor(account) sets up per-account refresh interval - src/start.ts: --accounts-file, --strategy flags; load + persist + parallel token init Tests: AccountPool picker + cooldown + acquire/release; loader file/legacy/empty paths; persistAccounts idempotency. Refs #2 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
All upstream-calling helpers now take an ApiContext (Account + VSCode version) instead of reading from the global state. Handlers acquire an account from the pool inline (real load-balancing comes in Task #4). - src/lib/api-config.ts: copilotBaseUrl/copilotHeaders/githubHeaders take ApiContext - src/services/{copilot,github}/*.ts: all signatures accept ctx as first arg - src/lib/state.ts: drop legacy githubToken/copilotToken fields - src/lib/token.ts: setupCopilotTokenFor uses makeApiContext; setupGitHubToken returns the token instead of mutating state - src/lib/utils.ts: makeApiContext / defaultApiContext helpers; cacheModels uses pool's first account - src/routes/{chat-completions,messages,embeddings}/*.ts: pool.acquire/release around upstream call - src/routes/usage,token/route.ts: use defaultApiContext / defaultAccount - src/check-usage.ts: build a temporary ApiContext from the loaded GitHub token Refs #3 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
- src/lib/with-account.ts: 401 -> refresh token + retry; 5xx/network -> cooldown + retry; 4xx propagates. Cap min(pool size, 3). - Handlers in chat-completions, messages, embeddings now go through withAccount. Tests: success, 5xx retry/cooldown, 4xx no retry, 401 refresh + retry, retries cap. Refs #4 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
- src/lib/usage-normalizer.ts: NormalizedUsage shape + helpers for OpenAI / Anthropic / embeddings, plus streaming accumulators that watch for include_usage / message_start+message_delta and finalize to NormalizedUsage. UsageMissingError raised if OpenAI stream finishes without usage. Tests: OpenAI/Anthropic/embeddings normalize, OpenAI accumulator capture + missing-usage error, Anthropic accumulator aggregation + sane zeros. Refs #5 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
- src/lib/usage-recorder.ts: recordUsage(input) inserts one usage_events row and atomically upserts the matching usage_daily row using SQLite's date(?/1000,'unixepoch','localtime') for the day boundary. Pricing snapshots are pulled from model_pricing at write time; missing rows leave snapshots NULL. premium_request_count = premium_multiplier (or 0). Errors are caught and logged so the response path is never broken. isInternal=true short-circuits the write entirely (for x-internal-pricing-sync calls). Tests: single insert, increment, missing pricing, isInternal, swallowed errors. Refs #6 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
After a successful non-streaming OpenAI response, the handler builds a NormalizedUsage from response.usage and calls recordUsage with status='ok', the chosen account, durationMs, requestId. On thrown errors before the recorder, a status='error' event with zero tokens is written. The x-internal-pricing-sync: 1 header is honored via isInternal. Streaming branch unchanged (Task #8 owns it). Refs #7 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
- create-chat-completions.ts forces stream_options.include_usage=true on streaming requests so the upstream emits a final usage frame. - Streaming branch uses an OpenAI accumulator: every chunk is parsed, fed, then forwarded untouched. After the stream closes, recordUsage is called with status='ok' (or 'aborted' if c.req.raw.signal.aborted, or 'error' on iteration error). Missing usage frame logs a warn and records zero tokens with status='error'. Refs #8 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Embeddings handler now wraps the upstream call with withAccount and records a usage_events row with endpoint='embeddings', upstreamFormat='openai'. On error, a status='error' row with zero usage is recorded. Refs #9 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
The /v1/messages route translates Anthropic→OpenAI before sending, so we use the OpenAI accumulator to capture usage. endpoint='messages', upstreamFormat='anthropic' tags the API surface. Stream branch: feed every chunk into accumulator, finalize on close (ok/aborted/error). Non-stream: normalize response.usage. Errors before the upstream call write a status='error' row. Refs #10 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
- src/lib/usage-stats.ts: computeUsageStats(filters) builds the three SQL templates (historical/current/timeline) and returns totals + byAccount.byModel.endpoint_breakdown + daily + missing_pricing per design doc 03.
- src/routes/usage/route.ts: parses ?from/?to/?account/?model/?endpoint/?lens; returns { ...primaryAccountQuotaForBackcompat, quota: { byAccount, primary }, stats }.
Tests: historical from snapshots, current uses live pricing, missing_pricing list, endpoint filter.
Refs #11
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
- src/lib/pricing-sources.ts: fetchAzureRetailPrices() pages through Azure Retail Prices API; fetchAnthropicPricingHtml() best-effort GET. - src/lib/pricing-sync.ts: pickSyncModel, buildSyncRequest, callSyncLlm (x-internal-pricing-sync + json_object), priceChanged (0.5%), sanityFails (10x). Tests: priceChanged, sanityFails, pickSyncModel. Refs #12 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
- src/lib/pricing-sync-runner.ts: runPricingSync({port, syncModel?, parsedOverride?}) glues task #12's parsed output into the DB:
- Sanity gate rejects whole sync on any 10x change.
- For each row, looks up current model_pricing_versions WHERE effective_to IS NULL, skips when priceChanged() is false.
- On change: patches old version's effective_to and inserts a new row; UPSERTs model_pricing.
- Writes pricing_sync_log + bumps meta.last_pricing_sync_ts inside the same transaction.
- Status: ok / partial / rejected / failed.
- src/lib/pricing-sync.ts: tighten null/undefined handling in priceChanged/sanityFails so the runner can pass DB-shaped rows.
Tests: first sync inserts versions; identical second sync no-op; changed price patches + inserts; 10x change rejects + leaves DB unchanged.
Refs #13
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Extract promptClaudeCodeSetup helper to stay within max-lines-per-function. Wire schedulePricingSync into start.ts with CLI flags: --pricing-sync-model, --pricing-sync-interval-days, --pricing-sync-disabled Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New `pricing-sync` CLI subcommand bootstraps a temporary server and runs a one-off pricing sync against Azure/Anthropic sources via the LLM extraction pipeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove response_format (not supported by all Copilot models) - Pre-filter Azure rows to only model-relevant entries (reduces payload) - Trim Anthropic HTML to pricing section (~10KB cap) - Strip markdown fences from LLM response - Log response body on error for easier debugging Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add `auth add`, `auth list`, and `auth remove` subcommands for interactive multi-account management. `auth add` runs Device Flow OAuth and auto-detects the GitHub username as account name. Accounts are stored in accounts.json. Legacy `auth` (bare) still works as before for backward compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests for readAccountsFile, writeAccountsFile, addAccountEntry, and removeAccountEntry covering round-trip, duplicate detection, removal, and missing file scenarios. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add runtime-adaptive SQLite adapter (bun:sqlite for Bun, better-sqlite3 for Node.js) - Support comma-separated multi-token --github-token with name:type:token format - Rewrite README with full feature documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add .gitattributes with eol=lf for cross-platform consistency - Add GitHub Actions workflow to auto-publish to npm on release Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Single tokens with name:type:token format were treated as raw tokens because parsing only triggered when comma was found. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Citty's underlying parser (mri) returns an array for repeated flags. Normalize string|string[] to comma-separated before parsing. Now both formats work: --github-token "a:individual:ghu_x,b:business:ghu_y" --github-token a:individual:ghu_x --github-token b:business:ghu_y Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add detectAccountInfo() that calls /user and /copilot_internal/user - Simplify --github-token format to just token or name:token - Account type (individual/business/enterprise) auto-detected from API - Username auto-detected when no name provided - Remove --account-type from auth add (always auto-detect) - Deduplicate account names with suffix when needed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Logs method, URL, and all headers of every incoming request to ~/.local/share/copilot-api/headers.log. Authorization values are truncated for safety. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add --record-requests, --record-dir, --record-parts CLI flags for opt-in HTTP request/response recording to per-request directories. Remove unconditional header-only logging. Disable sourcemaps in build to reduce npm package size. Bump to v0.9.0. Co-Authored-By: Claude Opus 4.6 <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
auth addsubcommand: Device Flow OAuth with auto-detected GitHub username as account name,--nameoverride, appends toaccounts.jsonauth listsubcommand: shows all accounts with name, type, and GitHub loginauth remove --name <name>subcommand: removes account by nameauthstill works as before (backward compatible)runDeviceFlow()fromtoken.tsACCOUNTS_FILE_PATHand account file CRUD helpers toaccounts-loader.tsTest plan
copilot-api auth add --name test→ device flow → account appended to~/.local/share/copilot-api/accounts.jsoncopilot-api auth list→ table of accounts with GitHub usernamescopilot-api auth remove --name test→ account removedcopilot-api auth→ legacy single-token flow still works🤖 Generated with Claude Code