opencode-usage-report 0.4.0 → 0.5.0

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/src/normalize.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { UsageWindow, WindowKind } from "./types";
2
-
3
1
  /** Accepts a number or a numeric string; anything else (including NaN/Infinity) yields null. */
4
2
  export function toNumber(v: unknown): number | null {
5
3
  if (typeof v === "number") {
@@ -45,31 +43,23 @@ export function pick(obj: unknown, ...keys: string[]): unknown {
45
43
  return undefined;
46
44
  }
47
45
 
48
- /** Converts a 0-1 ratio into a 0-100 percent, clamped to [0,100] and rounded to 1 decimal. */
49
- export function ratioToPercent(ratio: number): number {
50
- const rounded = Math.round(ratio * 1000) / 10;
51
- return Math.min(100, Math.max(0, rounded));
46
+ /** Finite number or null; numeric strings are not coerced. */
47
+ export function finiteOrNull(value: unknown): number | null {
48
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
49
+ }
50
+
51
+ /** Finite number >= 0 or null. */
52
+ export function nonNegative(value: unknown): number | null {
53
+ const n = finiteOrNull(value);
54
+ return n !== null && n >= 0 ? n : null;
55
+ }
56
+
57
+ /** Plain-object guard: null and arrays are not records. */
58
+ export function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
52
60
  }
53
61
 
54
- /** Builds a window from tolerant count fields, deriving usedPercent only when used and a positive limit are known. */
55
- export function windowFromCounts(
56
- kind: WindowKind,
57
- label: string,
58
- detail: { limit?: unknown; used?: unknown; remaining?: unknown; reset?: unknown },
59
- ): UsageWindow {
60
- const used = toNumber(detail.used);
61
- const limit = toNumber(detail.limit);
62
- const remaining = toNumber(detail.remaining);
63
- const usedPercent =
64
- used !== null && limit !== null && limit > 0 ? Math.round((used / limit) * 100) : null;
65
- return {
66
- kind,
67
- label,
68
- usedPercent,
69
- used,
70
- limit,
71
- remaining,
72
- resetsAt: toISODate(detail.reset),
73
- status: "ok",
74
- };
62
+ /** Tolerant string coercion; null and undefined become "". */
63
+ export function safeString(value: unknown): string {
64
+ return String(value ?? "");
75
65
  }
@@ -9,17 +9,10 @@ import type {
9
9
  import { AdapterError } from "../types";
10
10
  import { toISODate, toNumber } from "../normalize";
11
11
  import { redact } from "../auth";
12
+ import { asRecord, handleError, readBody, request } from "./http";
12
13
 
13
- const VERSION = "0.3.0";
14
- const USER_AGENT = "opencode-usage-report/" + VERSION;
15
14
  const ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
16
15
 
17
- function asRecord(v: unknown): Record<string, unknown> | null {
18
- return v !== null && typeof v === "object" && !Array.isArray(v)
19
- ? (v as Record<string, unknown>)
20
- : null;
21
- }
22
-
23
16
  /** "foo_bar-baz" -> "Foo Bar Baz". */
24
17
  function titleCase(v: string): string {
25
18
  return v
@@ -54,74 +47,6 @@ function windowKind(seconds: number | null): { kind: WindowKind; label: string }
54
47
  return { kind: "other", label: "Other" };
55
48
  }
56
49
 
57
- /** Reads the body once as text and best-effort parses JSON, so error and success paths share it. */
58
- async function readBody(res: Response): Promise<{ text: string; json: unknown }> {
59
- const text = await res.text().catch(() => "");
60
- let json: unknown = null;
61
- try {
62
- json = JSON.parse(text);
63
- } catch {
64
- json = null;
65
- }
66
- return { text, json };
67
- }
68
-
69
- async function attempt(cred: Credential, accountId: string, opts: FetchOptions): Promise<Response> {
70
- return fetch(ENDPOINT, {
71
- method: "GET",
72
- headers: {
73
- Authorization: "Bearer " + cred.key,
74
- "ChatGPT-Account-Id": accountId,
75
- Accept: "application/json",
76
- "User-Agent": USER_AGENT,
77
- },
78
- signal: AbortSignal.timeout(opts.timeoutMs),
79
- });
80
- }
81
-
82
- /** Exactly one retry on a network throw or 5xx; 4xx is returned immediately (never retried). */
83
- async function request(cred: Credential, accountId: string, opts: FetchOptions): Promise<Response> {
84
- try {
85
- const first = await attempt(cred, accountId, opts);
86
- if (first.status < 500) return first;
87
- } catch {
88
- // fall through to the single retry
89
- }
90
-
91
- try {
92
- return await attempt(cred, accountId, opts);
93
- } catch (err) {
94
- throw new AdapterError(
95
- "network",
96
- redact(
97
- `ChatGPT request failed: ${err instanceof Error ? err.message : String(err)}`,
98
- cred.key,
99
- ),
100
- );
101
- }
102
- }
103
-
104
- async function handleError(res: Response, cred: Credential): Promise<never> {
105
- const { text } = await readBody(res);
106
-
107
- if (res.status === 401) {
108
- throw new AdapterError("auth", redact("invalid ChatGPT login", cred.key));
109
- }
110
- if (res.status === 403) {
111
- throw new AdapterError("no-plan", redact("ChatGPT is not enabled for this account", cred.key));
112
- }
113
- if (res.status === 429) {
114
- throw new AdapterError("rate-limited", redact("ChatGPT rate limit reached", cred.key));
115
- }
116
- if (res.status >= 500) {
117
- throw new AdapterError("network", redact(`ChatGPT server error ${res.status}`, cred.key));
118
- }
119
- throw new AdapterError(
120
- "bad-response",
121
- redact(`ChatGPT unexpected status ${res.status}: ${text.slice(0, 200)}`, cred.key),
122
- );
123
- }
124
-
125
50
  export const chatgptAdapter: ProviderAdapter = {
126
51
  id: "openai",
127
52
  displayName: "ChatGPT",
@@ -130,8 +55,29 @@ export const chatgptAdapter: ProviderAdapter = {
130
55
  throw new AdapterError("auth", "ChatGPT account id missing (re-run: opencode auth login)");
131
56
  }
132
57
 
133
- const res = await request(cred, cred.accountId, opts);
134
- if (!res.ok) return handleError(res, cred);
58
+ const res = await request(cred, opts, {
59
+ endpoint: ENDPOINT,
60
+ label: "ChatGPT request failed",
61
+ headers: { "ChatGPT-Account-Id": cred.accountId },
62
+ });
63
+ if (!res.ok) {
64
+ return handleError(res, cred, (status, { text }) => {
65
+ if (status === 401) return { kind: "auth", message: "invalid ChatGPT login" };
66
+ if (status === 403) {
67
+ return { kind: "no-plan", message: "ChatGPT is not enabled for this account" };
68
+ }
69
+ if (status === 429) {
70
+ return { kind: "rate-limited", message: "ChatGPT rate limit reached" };
71
+ }
72
+ if (status >= 500) {
73
+ return { kind: "network", message: `ChatGPT server error ${status}` };
74
+ }
75
+ return {
76
+ kind: "bad-response",
77
+ message: `ChatGPT unexpected status ${status}: ${text.slice(0, 200)}`,
78
+ };
79
+ });
80
+ }
135
81
 
136
82
  const { json } = await readBody(res);
137
83
  const body = asRecord(json);
@@ -8,9 +8,8 @@ import type {
8
8
  import { AdapterError } from "../types";
9
9
  import { toISODate, toNumber } from "../normalize";
10
10
  import { redact } from "../auth";
11
+ import { asRecord, handleError, readBody, request } from "./http";
11
12
 
12
- const VERSION = "0.3.0";
13
- const USER_AGENT = "opencode-usage-report/" + VERSION;
14
13
  const ENDPOINT = "https://api.github.com/copilot_internal/user";
15
14
 
16
15
  /** Snapshot keys in display order, paired with their human-readable labels. */
@@ -20,12 +19,6 @@ const SNAPSHOTS: Array<{ key: string; label: string }> = [
20
19
  { key: "premium_interactions", label: "Premium interactions" },
21
20
  ];
22
21
 
23
- function asRecord(v: unknown): Record<string, unknown> | null {
24
- return v !== null && typeof v === "object" && !Array.isArray(v)
25
- ? (v as Record<string, unknown>)
26
- : null;
27
- }
28
-
29
22
  /** "foo_bar-baz" -> "Foo Bar Baz". */
30
23
  function titleCase(v: string): string {
31
24
  return v
@@ -60,88 +53,37 @@ export function copilotTier(sku: unknown, plan: unknown): string {
60
53
  return "Copilot";
61
54
  }
62
55
 
63
- /** Reads the body once as text and best-effort parses JSON, so error and success paths share it. */
64
- async function readBody(res: Response): Promise<{ text: string; json: unknown }> {
65
- const text = await res.text().catch(() => "");
66
- let json: unknown = null;
67
- try {
68
- json = JSON.parse(text);
69
- } catch {
70
- json = null;
71
- }
72
- return { text, json };
73
- }
74
-
75
- async function attempt(cred: Credential, opts: FetchOptions): Promise<Response> {
76
- return fetch(ENDPOINT, {
77
- method: "GET",
78
- headers: {
79
- Authorization: "Bearer " + cred.key,
80
- Accept: "application/json",
81
- "User-Agent": USER_AGENT,
82
- },
83
- signal: AbortSignal.timeout(opts.timeoutMs),
84
- });
85
- }
86
-
87
- /** Exactly one retry on a network throw or 5xx; 4xx is returned immediately (never retried). */
88
- async function request(cred: Credential, opts: FetchOptions): Promise<Response> {
89
- try {
90
- const first = await attempt(cred, opts);
91
- if (first.status < 500) return first;
92
- } catch {
93
- // fall through to the single retry
94
- }
95
-
96
- try {
97
- return await attempt(cred, opts);
98
- } catch (err) {
99
- throw new AdapterError(
100
- "network",
101
- redact(
102
- `GitHub Copilot request failed: ${err instanceof Error ? err.message : String(err)}`,
103
- cred.key,
104
- ),
105
- );
106
- }
107
- }
108
-
109
- async function handleError(res: Response, cred: Credential): Promise<never> {
110
- const { text } = await readBody(res);
111
-
112
- if (res.status === 401 || res.status === 404) {
113
- throw new AdapterError(
114
- "auth",
115
- redact("GitHub Copilot login invalid or not available for this account", cred.key),
116
- );
117
- }
118
- if (res.status === 403) {
119
- throw new AdapterError(
120
- "no-plan",
121
- redact("GitHub Copilot is not enabled for this account", cred.key),
122
- );
123
- }
124
- if (res.status === 429) {
125
- throw new AdapterError("rate-limited", redact("GitHub Copilot rate limit reached", cred.key));
126
- }
127
- if (res.status >= 500) {
128
- throw new AdapterError(
129
- "network",
130
- redact(`GitHub Copilot server error ${res.status}`, cred.key),
131
- );
132
- }
133
- throw new AdapterError(
134
- "bad-response",
135
- redact(`GitHub Copilot unexpected status ${res.status}: ${text.slice(0, 200)}`, cred.key),
136
- );
137
- }
138
-
139
56
  export const copilotAdapter: ProviderAdapter = {
140
57
  id: "github-copilot",
141
58
  displayName: "GitHub Copilot",
142
59
  async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
143
- const res = await request(cred, opts);
144
- if (!res.ok) return handleError(res, cred);
60
+ const res = await request(cred, opts, {
61
+ endpoint: ENDPOINT,
62
+ label: "GitHub Copilot request failed",
63
+ });
64
+ if (!res.ok) {
65
+ return handleError(res, cred, (status, { text }) => {
66
+ if (status === 401 || status === 404) {
67
+ return {
68
+ kind: "auth",
69
+ message: "GitHub Copilot login invalid or not available for this account",
70
+ };
71
+ }
72
+ if (status === 403) {
73
+ return { kind: "no-plan", message: "GitHub Copilot is not enabled for this account" };
74
+ }
75
+ if (status === 429) {
76
+ return { kind: "rate-limited", message: "GitHub Copilot rate limit reached" };
77
+ }
78
+ if (status >= 500) {
79
+ return { kind: "network", message: `GitHub Copilot server error ${status}` };
80
+ }
81
+ return {
82
+ kind: "bad-response",
83
+ message: `GitHub Copilot unexpected status ${status}: ${text.slice(0, 200)}`,
84
+ };
85
+ });
86
+ }
145
87
 
146
88
  const { json } = await readBody(res);
147
89
  const body = asRecord(json);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shared HTTP plumbing for provider adapters. Every adapter performs the same
3
+ * authenticated GET with one retry, so the fetch, body-reading and error-mapping
4
+ * helpers live here instead of being copy-pasted per provider.
5
+ */
6
+ import type { AdapterErrorKind, Credential, FetchOptions } from "../types";
7
+ import { AdapterError } from "../types";
8
+ import { redact } from "../auth";
9
+
10
+ /** Sent as the User-Agent on every provider request; keep in sync with package.json. */
11
+ export const USER_AGENT = "opencode-usage-report/0.4.1";
12
+
13
+ export function asRecord(v: unknown): Record<string, unknown> | null {
14
+ return v !== null && typeof v === "object" && !Array.isArray(v)
15
+ ? (v as Record<string, unknown>)
16
+ : null;
17
+ }
18
+
19
+ export interface Body {
20
+ text: string;
21
+ json: unknown;
22
+ }
23
+
24
+ /** Reads the body once as text and best-effort parses JSON, so error and success paths share it. */
25
+ export async function readBody(res: Response): Promise<Body> {
26
+ const text = await res.text().catch(() => "");
27
+ let json: unknown = null;
28
+ try {
29
+ json = JSON.parse(text);
30
+ } catch {
31
+ json = null;
32
+ }
33
+ return { text, json };
34
+ }
35
+
36
+ export interface RequestSpec {
37
+ endpoint: string;
38
+ /** Prefix of the thrown network error, e.g. "Kimi API network error". */
39
+ label: string;
40
+ headers?: Record<string, string>;
41
+ }
42
+
43
+ function attempt(cred: Credential, opts: FetchOptions, spec: RequestSpec): Promise<Response> {
44
+ return fetch(spec.endpoint, {
45
+ method: "GET",
46
+ headers: {
47
+ Authorization: "Bearer " + cred.key,
48
+ Accept: "application/json",
49
+ "User-Agent": USER_AGENT,
50
+ ...spec.headers,
51
+ },
52
+ signal: AbortSignal.timeout(opts.timeoutMs),
53
+ });
54
+ }
55
+
56
+ /** Exactly one retry on a network throw or 5xx; 4xx is returned immediately (never retried). */
57
+ export async function request(
58
+ cred: Credential,
59
+ opts: FetchOptions,
60
+ spec: RequestSpec,
61
+ ): Promise<Response> {
62
+ let lastError: unknown = null;
63
+ for (let i = 0; i < 2; i++) {
64
+ try {
65
+ const res = await attempt(cred, opts, spec);
66
+ if (res.status >= 500 && i === 0) continue;
67
+ return res;
68
+ } catch (err) {
69
+ lastError = err;
70
+ }
71
+ }
72
+ const detail = lastError instanceof Error ? lastError.message : String(lastError);
73
+ throw new AdapterError("network", redact(`${spec.label}: ${detail}`, cred.key));
74
+ }
75
+
76
+ export interface ErrorResolution {
77
+ kind: AdapterErrorKind;
78
+ message: string;
79
+ }
80
+
81
+ /** Reads the error body, asks the adapter to map it, and throws the resulting AdapterError. */
82
+ export async function handleError(
83
+ res: Response,
84
+ cred: Credential,
85
+ resolve: (status: number, body: Body) => ErrorResolution,
86
+ ): Promise<never> {
87
+ const body = await readBody(res);
88
+ const { kind, message } = resolve(res.status, body);
89
+ throw new AdapterError(kind, redact(message, cred.key));
90
+ }
@@ -4,12 +4,12 @@ import type {
4
4
  FetchOptions,
5
5
  ProviderAdapter,
6
6
  UsageWindow,
7
+ WindowKind,
7
8
  } from "../types";
8
9
  import { AdapterError } from "../types";
9
- import { pick, ratioToPercent, toISODate, toNumber, windowFromCounts } from "../normalize";
10
+ import { pick, toISODate, toNumber } from "../normalize";
10
11
  import { redact } from "../auth";
11
-
12
- const VERSION = "0.3.0";
12
+ import { handleError, readBody, request } from "./http";
13
13
 
14
14
  /**
15
15
  * Kimi Code is split by region in opencode/models.dev: the global plan lives on
@@ -23,39 +23,33 @@ export interface KimiAdapterConfig {
23
23
  baseUrl: string;
24
24
  }
25
25
 
26
- function request(cred: Credential, opts: FetchOptions, endpoint: string): Promise<Response> {
27
- return fetch(endpoint, {
28
- method: "GET",
29
- headers: {
30
- Authorization: "Bearer " + cred.key,
31
- Accept: "application/json",
32
- "User-Agent": "opencode-usage-report/" + VERSION,
33
- },
34
- signal: AbortSignal.timeout(opts.timeoutMs),
35
- });
26
+ /** Converts a 0-1 ratio into a 0-100 percent, clamped to [0,100] and rounded to 1 decimal. */
27
+ function ratioToPercent(ratio: number): number {
28
+ const rounded = Math.round(ratio * 1000) / 10;
29
+ return Math.min(100, Math.max(0, rounded));
36
30
  }
37
31
 
38
- /** One retry on network throw or 5xx; 4xx and 2xx return immediately. */
39
- async function fetchWithRetry(
40
- cred: Credential,
41
- opts: FetchOptions,
42
- endpoint: string,
43
- ): Promise<Response> {
44
- let lastError: unknown = null;
45
- for (let attempt = 0; attempt < 2; attempt++) {
46
- try {
47
- const res = await request(cred, opts, endpoint);
48
- if (res.status >= 500) {
49
- lastError = new Error(`HTTP ${res.status}`);
50
- continue;
51
- }
52
- return res;
53
- } catch (err) {
54
- lastError = err;
55
- }
56
- }
57
- const detail = lastError instanceof Error ? lastError.message : String(lastError);
58
- throw new AdapterError("network", redact(`Kimi API network error: ${detail}`, cred.key));
32
+ /** Builds a window from tolerant count fields, deriving usedPercent only when used and a positive limit are known. */
33
+ function windowFromCounts(
34
+ kind: WindowKind,
35
+ label: string,
36
+ detail: { limit?: unknown; used?: unknown; remaining?: unknown; reset?: unknown },
37
+ ): UsageWindow {
38
+ const used = toNumber(detail.used);
39
+ const limit = toNumber(detail.limit);
40
+ const remaining = toNumber(detail.remaining);
41
+ const usedPercent =
42
+ used !== null && limit !== null && limit > 0 ? Math.round((used / limit) * 100) : null;
43
+ return {
44
+ kind,
45
+ label,
46
+ usedPercent,
47
+ used,
48
+ limit,
49
+ remaining,
50
+ resetsAt: toISODate(detail.reset),
51
+ status: "ok",
52
+ };
59
53
  }
60
54
 
61
55
  function findLimitDetail(limits: unknown, durationMinutes: number): unknown {
@@ -174,39 +168,33 @@ export function createKimiAdapter(config: KimiAdapterConfig): ProviderAdapter {
174
168
  id: config.id,
175
169
  displayName: config.displayName,
176
170
  async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
177
- const res = await fetchWithRetry(cred, opts, endpoint);
178
-
179
- if (res.status === 401) {
180
- throw new AdapterError("auth", redact("invalid Kimi API key", cred.key));
181
- }
182
- if (res.status === 429) {
183
- throw new AdapterError("rate-limited", redact("Kimi API rate limit exceeded", cred.key));
184
- }
185
- if (res.status < 200 || res.status >= 300) {
186
- throw new AdapterError(
187
- "bad-response",
188
- redact(`Kimi API returned HTTP ${res.status}`, cred.key),
189
- );
190
- }
191
-
192
- let body: unknown;
193
- try {
194
- body = await res.json();
195
- } catch {
196
- throw new AdapterError(
197
- "bad-response",
198
- redact("Kimi API returned a non-JSON response", cred.key),
199
- );
171
+ const res = await request(cred, opts, {
172
+ endpoint,
173
+ label: "Kimi API network error",
174
+ });
175
+
176
+ if (!res.ok) {
177
+ return handleError(res, cred, (status) => {
178
+ if (status === 401) return { kind: "auth", message: "invalid Kimi API key" };
179
+ if (status === 429) {
180
+ return { kind: "rate-limited", message: "Kimi API rate limit exceeded" };
181
+ }
182
+ if (status >= 500) {
183
+ return { kind: "network", message: `Kimi API network error: HTTP ${status}` };
184
+ }
185
+ return { kind: "bad-response", message: `Kimi API returned HTTP ${status}` };
186
+ });
200
187
  }
201
188
 
202
- if (body === null || typeof body !== "object" || Array.isArray(body)) {
189
+ const { json } = await readBody(res);
190
+ if (json === null || typeof json !== "object" || Array.isArray(json)) {
203
191
  throw new AdapterError(
204
192
  "bad-response",
205
193
  redact("Kimi API returned an unexpected response shape", cred.key),
206
194
  );
207
195
  }
208
196
 
209
- return parse(body as Record<string, unknown>, cred);
197
+ return parse(json as Record<string, unknown>, cred);
210
198
  },
211
199
  };
212
200
  }