aisubs 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 - 2026-10-06
4
+
5
+ - Add `SubscriptionAuth.onUsage(listener)`: usage that provider responses report about
6
+ themselves, with no extra request. ChatGPT reads its `x-codex-*` rate-limit headers and
7
+ GitHub Copilot its `x-quota-snapshot-*` headers; the cached usage snapshot follows along.
8
+ - Add the `usageFromResponse` adapter hook, plus `parseChatGptUsageHeaders`,
9
+ `parseCopilotUsageHeaders` and `mergeObservedUsage`.
10
+ - `getUsage` (and `SubscriptionAccount.getUsage`) take `force` to bypass the usage cache.
11
+
3
12
  ## 0.4.0 - 2026-10-06
4
13
 
5
14
  - Breaking: removed the Claude subscription provider, the `aisubs/providers/claude`
package/dist/auth.d.ts CHANGED
@@ -18,7 +18,8 @@ export interface SubscriptionAccount {
18
18
  getAccessToken(): Promise<string>;
19
19
  fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
20
20
  proxy(path: string, init?: RequestInit): Promise<Response>;
21
- getUsage(signal?: AbortSignal): Promise<ProviderUsage | null>;
21
+ /** `force` reads the provider now instead of answering from the short usage cache. */
22
+ getUsage(signal?: AbortSignal, force?: boolean): Promise<ProviderUsage | null>;
22
23
  getModels(signal?: AbortSignal, force?: boolean): Promise<ProviderModels | null>;
23
24
  credentialSummary(): Promise<CredentialSummary>;
24
25
  details(signal?: AbortSignal): Promise<SubscriptionAccountDetails>;
@@ -35,6 +36,7 @@ export declare class SubscriptionAuth {
35
36
  private readonly usageInflight;
36
37
  private readonly modelsInflight;
37
38
  private readonly metadataGenerations;
39
+ private readonly usageListeners;
38
40
  private readonly refreshTimeoutMs;
39
41
  private readonly usageCacheTtlMs;
40
42
  private readonly modelsCacheTtlMs;
@@ -67,11 +69,18 @@ export declare class SubscriptionAuth {
67
69
  getAccessToken(provider: ProviderId, account?: string): Promise<string>;
68
70
  credentialSummary(provider: ProviderId, account?: string): Promise<CredentialSummary>;
69
71
  details(provider: ProviderId, account?: string, signal?: AbortSignal): Promise<SubscriptionAccountDetails>;
72
+ /**
73
+ * Calls `listener` with the usage each provider response reports about itself (for example
74
+ * ChatGPT's rate-limit headers): only the meters that response mentions, as of its arrival.
75
+ * The cached usage snapshot is updated too. Returns the unsubscribe function.
76
+ */
77
+ onUsage(listener: (usage: ProviderUsage) => void): () => void;
78
+ private observeUsage;
70
79
  fetch(provider: ProviderId, input: string | URL | Request, init?: RequestInit, account?: string): Promise<Response>;
71
80
  proxy(provider: ProviderId, account: string, path: string, init?: RequestInit): Promise<Response>;
72
81
  /** Build an authorized direct-provider request for transports such as WebSocket. */
73
82
  authorizeProxyRequest(provider: ProviderId, account: string, path: string, init?: RequestInit): Promise<Request>;
74
- getUsage(provider: ProviderId, account?: string, callerSignal?: AbortSignal): Promise<ProviderUsage | null>;
83
+ getUsage(provider: ProviderId, account?: string, callerSignal?: AbortSignal, force?: boolean): Promise<ProviderUsage | null>;
75
84
  getModels(provider: ProviderId, account?: string, callerSignal?: AbortSignal, force?: boolean): Promise<ProviderModels | null>;
76
85
  account(provider: ProviderId, account: string): SubscriptionAccount;
77
86
  }
package/dist/auth.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mergeObservedUsage } from "./usage.js";
1
2
  import { errorMessage } from "./utils.js";
2
3
  export const DEFAULT_ACCOUNT = "default";
3
4
  const ACCOUNT_STORAGE_PREFIX = "$subscription-account$";
@@ -73,6 +74,7 @@ export class SubscriptionAuth {
73
74
  usageInflight = new Map();
74
75
  modelsInflight = new Map();
75
76
  metadataGenerations = new Map();
77
+ usageListeners = new Set();
76
78
  refreshTimeoutMs;
77
79
  usageCacheTtlMs;
78
80
  modelsCacheTtlMs;
@@ -498,6 +500,38 @@ export class SubscriptionAuth {
498
500
  ]);
499
501
  return { session, credential, usage, models };
500
502
  }
503
+ /**
504
+ * Calls `listener` with the usage each provider response reports about itself (for example
505
+ * ChatGPT's rate-limit headers): only the meters that response mentions, as of its arrival.
506
+ * The cached usage snapshot is updated too. Returns the unsubscribe function.
507
+ */
508
+ onUsage(listener) {
509
+ this.usageListeners.add(listener);
510
+ return () => this.usageListeners.delete(listener);
511
+ }
512
+ observeUsage(adapter, provider, accountKey, response) {
513
+ let data;
514
+ try {
515
+ data = adapter.usageFromResponse?.(response) ?? null;
516
+ }
517
+ catch {
518
+ return;
519
+ }
520
+ if (!data)
521
+ return;
522
+ const usage = { provider, accountKey, asOf: Date.now(), ...data };
523
+ const scope = credentialKey(provider, accountKey);
524
+ const cached = this.usageCache.get(scope);
525
+ if (cached?.value) {
526
+ cached.value = { ...mergeObservedUsage(cached.value, usage), asOf: usage.asOf };
527
+ }
528
+ for (const listener of this.usageListeners) {
529
+ try {
530
+ listener(usage);
531
+ }
532
+ catch { }
533
+ }
534
+ }
501
535
  async fetch(provider, input, init, account = DEFAULT_ACCOUNT) {
502
536
  const adapter = this.adapter(provider);
503
537
  const original = createRequest(input, init);
@@ -509,6 +543,7 @@ export class SubscriptionAuth {
509
543
  headers.set("cache-control", "no-store");
510
544
  const request = new Request(authorized, { cache: "no-store", headers });
511
545
  const response = await globalThis.fetch(request);
546
+ this.observeUsage(adapter, provider, accountKey, response);
512
547
  return adapter.normalizeResponse?.(request, response) ?? response;
513
548
  };
514
549
  if (adapter.supportsFetch === false) {
@@ -560,12 +595,17 @@ export class SubscriptionAuth {
560
595
  const normalized = (await adapter.normalizeRequest?.(request.clone())) ?? request;
561
596
  return adapter.authorize(normalized, credential);
562
597
  }
563
- async getUsage(provider, account = DEFAULT_ACCOUNT, callerSignal) {
598
+ async getUsage(provider, account = DEFAULT_ACCOUNT, callerSignal, force = false) {
564
599
  const adapter = this.adapter(provider);
565
600
  if (!adapter.getUsage)
566
601
  return null;
567
602
  const accountKey = normalizeAccountKey(account);
568
603
  const scope = credentialKey(provider, accountKey);
604
+ if (force) {
605
+ this.usageCache.delete(scope);
606
+ this.usageInflight.delete(scope);
607
+ this.metadataGenerations.set(scope, (this.metadataGenerations.get(scope) ?? 0) + 1);
608
+ }
569
609
  return this.cachedMetadata(this.usageCache, this.usageInflight, scope, this.usageCacheTtlMs, callerSignal, async () => {
570
610
  const credential = await this.credential(provider, accountKey);
571
611
  const timeout = AbortSignal.timeout(this.refreshTimeoutMs);
@@ -613,7 +653,7 @@ export class SubscriptionAuth {
613
653
  getAccessToken: () => this.getAccessToken(provider, accountKey),
614
654
  fetch: (input, init) => this.fetch(provider, input, init, accountKey),
615
655
  proxy: (path, init) => this.proxy(provider, accountKey, path, init),
616
- getUsage: (signal) => this.getUsage(provider, accountKey, signal),
656
+ getUsage: (signal, force) => this.getUsage(provider, accountKey, signal, force),
617
657
  getModels: (signal, force) => this.getModels(provider, accountKey, signal, force),
618
658
  credentialSummary: () => this.credentialSummary(provider, accountKey),
619
659
  details: (signal) => this.details(provider, accountKey, signal),
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { createSubscriptionAuth, DEFAULT_ACCOUNT, SubscriptionAuth, type SubscriptionAccount, type SubscriptionAuthOptions, } from "./auth.js";
2
2
  export { MemoryCredentialStore } from "./memory-store.js";
3
- export { parseChatGptUsage, parseCopilotUsage, parseGrokUsage } from "./usage.js";
3
+ export { mergeObservedUsage, parseChatGptUsage, parseChatGptUsageHeaders, parseCopilotUsage, parseCopilotUsageHeaders, parseGrokUsage, } from "./usage.js";
4
4
  export type { CredentialStore, CoordinatedCredentialStore, CredentialSummary, BrowserLoginPrompt, DeviceLoginPrompt, ImmediateLoginPrompt, LoginAttempt, LoginMode, LoginPrompt, LoginState, OAuthCredential, VersionedCredential, ProviderAdapter, ProviderId, ProviderLogin, ProviderLoginField, ProviderModel, ProviderModels, ProviderSummary, ProviderUsage, ProviderUsageContext, ProviderUsageData, Session, SubscriptionAccountDetails, UsageFact, UsageMeter, UsageResetCredit, UsageResetCredits, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { createSubscriptionAuth, DEFAULT_ACCOUNT, SubscriptionAuth, } from "./auth.js";
2
2
  export { MemoryCredentialStore } from "./memory-store.js";
3
- export { parseChatGptUsage, parseCopilotUsage, parseGrokUsage } from "./usage.js";
3
+ export { mergeObservedUsage, parseChatGptUsage, parseChatGptUsageHeaders, parseCopilotUsage, parseCopilotUsageHeaders, parseGrokUsage, } from "./usage.js";
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
- import { parseChatGptUsage } from "../usage.js";
3
+ import { parseChatGptUsage, parseChatGptUsageHeaders } from "../usage.js";
4
4
  import { abortableDelay, bearerRequest, isRecord, numberValue, requireAllowedHost, responseJson, stringArray, stringValue, } from "../utils.js";
5
5
  const ISSUER = "https://auth.openai.com";
6
6
  const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -533,6 +533,7 @@ export function chatGptProvider(options = {}) {
533
533
  }
534
534
  return response;
535
535
  },
536
+ usageFromResponse: (response) => parseChatGptUsageHeaders(response.headers),
536
537
  async getUsage({ fetch, signal }) {
537
538
  const response = await fetch(USAGE_URL, { headers: { accept: "application/json" }, signal });
538
539
  const raw = await responseJson(response, "ChatGPT usage");
@@ -1,4 +1,4 @@
1
- import { copilotPlanName, parseCopilotUsage } from "../usage.js";
1
+ import { copilotPlanName, parseCopilotUsage, parseCopilotUsageHeaders } from "../usage.js";
2
2
  import { abortableDelay, bearerRequest, isRecord, numberValue, requireAllowedHost, responseJson, stringArray, stringValue, } from "../utils.js";
3
3
  const DEFAULT_CLIENT_ID = "Iv1.b507a08c87ecfe98";
4
4
  const EXPIRY_SKEW_MS = 5 * 60_000;
@@ -448,6 +448,7 @@ export function copilotProvider(options = {}) {
448
448
  headers,
449
449
  });
450
450
  },
451
+ usageFromResponse: (response) => parseCopilotUsageHeaders(response.headers),
451
452
  async getUsage({ credential, signal }) {
452
453
  if (!credential.refreshToken)
453
454
  throw new Error("GitHub token is missing");
package/dist/types.d.ts CHANGED
@@ -189,6 +189,11 @@ export interface ProviderAdapter {
189
189
  refresh(credential: OAuthCredential, signal: AbortSignal): Promise<OAuthCredential>;
190
190
  authorize(request: Request, credential: OAuthCredential): Request | Promise<Request>;
191
191
  getUsage?(context: ProviderUsageContext): Promise<ProviderUsageData | null>;
192
+ /**
193
+ * Usage a model response reports about itself (rate-limit or quota headers), so meters stay
194
+ * current with no extra request. Only the meters the response mentions; `null` when none.
195
+ */
196
+ usageFromResponse?(response: Response): ProviderUsageData | null;
192
197
  getModels?(context: ProviderUsageContext): Promise<ProviderModel[]>;
193
198
  isPermanentRefreshError?(error: unknown): boolean;
194
199
  }
package/dist/usage.d.ts CHANGED
@@ -3,3 +3,21 @@ export declare function parseChatGptUsage(raw: unknown, detailedResetCredits?: u
3
3
  export declare function copilotPlanName(raw: unknown): string | undefined;
4
4
  export declare function parseCopilotUsage(raw: unknown): ProviderUsageData | null;
5
5
  export declare function parseGrokUsage(raw: unknown, userRaw?: unknown, settingsRaw?: unknown): ProviderUsageData | null;
6
+ /**
7
+ * The plan windows a ChatGPT (Codex) response reports in its headers, the same numbers the usage
8
+ * endpoint returns: `x-codex-primary-used-percent`, `-window-minutes` and `-reset-at` (unix
9
+ * seconds), likewise `secondary`, and other metered families as `x-<limit>-primary-…`. Meter ids
10
+ * match `parseChatGptUsage`, so a reading updates the meters a usage read listed.
11
+ */
12
+ export declare function parseChatGptUsageHeaders(headers: Headers): ProviderUsageData | null;
13
+ /**
14
+ * The quotas a GitHub Copilot response reports in `x-quota-snapshot-<quota>` headers, as VS Code
15
+ * reads them: URL-encoded `ent` (entitlement, -1 unlimited), `rem` (percent remaining) and `rst`
16
+ * (reset time). Meter ids match `parseCopilotUsage`'s quota snapshot keys.
17
+ */
18
+ export declare function parseCopilotUsageHeaders(headers: Headers): ProviderUsageData | null;
19
+ /**
20
+ * `base` with the meters a response reported (`observed`) replaced by id. Meters `base` doesn't
21
+ * list are left out: a response reports numbers, the usage read decides which meters exist.
22
+ */
23
+ export declare function mergeObservedUsage<T extends ProviderUsageData>(base: T, observed: ProviderUsageData): T;
package/dist/usage.js CHANGED
@@ -416,3 +416,85 @@ export function parseGrokUsage(raw, userRaw, settingsRaw) {
416
416
  : undefined,
417
417
  };
418
418
  }
419
+ /**
420
+ * The plan windows a ChatGPT (Codex) response reports in its headers, the same numbers the usage
421
+ * endpoint returns: `x-codex-primary-used-percent`, `-window-minutes` and `-reset-at` (unix
422
+ * seconds), likewise `secondary`, and other metered families as `x-<limit>-primary-…`. Meter ids
423
+ * match `parseChatGptUsage`, so a reading updates the meters a usage read listed.
424
+ */
425
+ export function parseChatGptUsageHeaders(headers) {
426
+ const families = new Set();
427
+ for (const name of headers.keys()) {
428
+ const match = /^x-(.+)-(?:primary|secondary)-used-percent$/.exec(name.toLowerCase());
429
+ if (match)
430
+ families.add(match[1]);
431
+ }
432
+ const meters = [];
433
+ for (const family of families) {
434
+ for (const window of ["primary", "secondary"]) {
435
+ const prefix = `x-${family}-${window}`;
436
+ const percentUsed = numeric(headers.get(`${prefix}-used-percent`));
437
+ if (percentUsed == null)
438
+ continue;
439
+ const minutes = numeric(headers.get(`${prefix}-window-minutes`));
440
+ const resetAt = timestamp(headers.get(`${prefix}-reset-at`));
441
+ const name = headers.get(`x-${family}-limit-name`)?.trim();
442
+ const label = windowLabel(minutes != null ? minutes * 60 : undefined);
443
+ meters.push({
444
+ id: family === "codex" ? window : `${family.replace(/-/g, "_")}:${window}`,
445
+ label: family === "codex" ? label : `${name || title(family)} / ${label}`,
446
+ unit: "percent",
447
+ percentUsed,
448
+ resetAt,
449
+ });
450
+ }
451
+ }
452
+ return meters.length ? { meters } : null;
453
+ }
454
+ /**
455
+ * The quotas a GitHub Copilot response reports in `x-quota-snapshot-<quota>` headers, as VS Code
456
+ * reads them: URL-encoded `ent` (entitlement, -1 unlimited), `rem` (percent remaining) and `rst`
457
+ * (reset time). Meter ids match `parseCopilotUsage`'s quota snapshot keys.
458
+ */
459
+ export function parseCopilotUsageHeaders(headers) {
460
+ const meters = [];
461
+ for (const [name, value] of headers) {
462
+ const match = /^x-quota-snapshot-(.+)$/.exec(name.toLowerCase());
463
+ if (!match)
464
+ continue;
465
+ const params = new URLSearchParams(value);
466
+ const entitlement = numeric(params.get("ent"));
467
+ const percentRemaining = numeric(params.get("rem"));
468
+ if (entitlement == null || percentRemaining == null)
469
+ continue;
470
+ const id = match[1];
471
+ const resetAt = timestamp(params.get("rst"));
472
+ meters.push(entitlement === -1
473
+ ? copilotMeter(id, { unlimited: true }, resetAt)
474
+ : copilotMeter(id, {
475
+ entitlement,
476
+ quota_remaining: (entitlement * percentRemaining) / 100,
477
+ percent_remaining: percentRemaining,
478
+ }, resetAt));
479
+ }
480
+ return meters.length ? { meters } : null;
481
+ }
482
+ /**
483
+ * `base` with the meters a response reported (`observed`) replaced by id. Meters `base` doesn't
484
+ * list are left out: a response reports numbers, the usage read decides which meters exist.
485
+ */
486
+ export function mergeObservedUsage(base, observed) {
487
+ if (!base.meters?.length || !observed.meters?.length)
488
+ return base;
489
+ const byId = new Map(observed.meters.map((meter) => [meter.id, meter]));
490
+ let changed = false;
491
+ const meters = base.meters.map((meter) => {
492
+ const next = byId.get(meter.id);
493
+ if (!next)
494
+ return meter;
495
+ changed = true;
496
+ // The read's label and unit name the meter; the response only brings its numbers.
497
+ return { ...next, label: meter.label, unit: meter.unit, window: meter.window };
498
+ });
499
+ return changed ? { ...base, meters } : base;
500
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aisubs",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Connect AI provider accounts and use those subscriptions from any local tool or as api.",
5
5
  "keywords": [
6
6
  "ai",