opencode-usage-report 0.3.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.
@@ -0,0 +1,50 @@
1
+ import type { Message, Model, Part } from "@opencode-ai/sdk/v2";
2
+
3
+ export type RowKey = "user" | "agent" | "reasoning" | "tools" | "system" | "free";
4
+
5
+ export interface ContextRow {
6
+ key: RowKey;
7
+ label: string;
8
+ tokens: number | null;
9
+ percent: number | null; // 0-100, null when the limit is unknown
10
+ }
11
+
12
+ export interface GridCell {
13
+ rowKey: RowKey | null; // null = free space
14
+ fill: number; // 0..1
15
+ }
16
+
17
+ export interface ContextBreakdown {
18
+ ready: boolean;
19
+ modelName: string | null;
20
+ total: number | null;
21
+ limit: number | null;
22
+ cost: number;
23
+ rows: ContextRow[];
24
+ grid: { cols: number; rows: number; cells: GridCell[] };
25
+ headroom: { usable: number | null; free: number | null; band: "ok" | "warning" | "error" };
26
+ systemDerived: boolean;
27
+ /** Measured system-prompt tokens when a valid capture was supplied, else null. */
28
+ systemCaptured: number | null;
29
+ prunedToolOutputs: number;
30
+ }
31
+
32
+ export interface SystemCapture {
33
+ version: 1;
34
+ sessionID: string;
35
+ providerID: string;
36
+ modelID: string;
37
+ systemChars: number;
38
+ systemTokens: number;
39
+ capturedAt: number;
40
+ }
41
+
42
+ export interface CollectInput {
43
+ messages: readonly Message[];
44
+ model: Model | null;
45
+ part?: ((messageID: string) => readonly Part[]) | null;
46
+ system?: SystemCapture | null;
47
+ sessionCost?: number;
48
+ cols?: number | null;
49
+ compaction?: { reserved?: number } | null;
50
+ }
package/src/fallback.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import type { AdapterResult, UsageWindow } from "./types";
3
3
  import { dbPath } from "./paths";
4
+ import { asRecord } from "./providers/http";
4
5
 
5
6
  const HOUR_MS = 60 * 60 * 1000;
6
7
  const DAY_MS = 24 * HOUR_MS;
@@ -15,12 +16,6 @@ interface SqliteDatabase {
15
16
  }
16
17
  type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => SqliteDatabase;
17
18
 
18
- function asRecord(value: unknown): Record<string, unknown> | null {
19
- return value !== null && typeof value === "object" && !Array.isArray(value)
20
- ? (value as Record<string, unknown>)
21
- : null;
22
- }
23
-
24
19
  function toNum(value: unknown): number | null {
25
20
  return typeof value === "number" && Number.isFinite(value) ? value : null;
26
21
  }
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@ import { renderJson, renderText } from "./render";
5
5
  import { checkWarnings } from "./warn";
6
6
  import { toNumber } from "./normalize";
7
7
  import { adapters } from "./providers/index";
8
+ import { estimateTokens } from "./context/estimate";
9
+ import { writeCapture } from "./context/system";
8
10
  import type { PluginOptions } from "./types";
9
11
 
10
12
  const WARN_THROTTLE_MS = 10 * 60 * 1000;
@@ -13,9 +15,6 @@ const TOOL_DESCRIPTION =
13
15
  "Show subscription usage/quota windows (5h, weekly, monthly) for configured inference providers " +
14
16
  `(${adapters.map((adapter) => adapter.id).join(", ")})`;
15
17
 
16
- const COMMAND_TEMPLATE =
17
- "Call the usage_report tool with these arguments: $ARGUMENTS and present the result verbatim.";
18
-
19
18
  /** Merges user plugin options over defaults, defensively ignoring malformed values. */
20
19
  export function coerceOptions(raw: Record<string, unknown> | undefined): PluginOptions {
21
20
  const opts: PluginOptions = { ...DEFAULT_OPTIONS };
@@ -91,12 +90,31 @@ const plugin: Plugin = async ({ client }, options) => {
91
90
  }),
92
91
  },
93
92
 
94
- async config(cfg) {
95
- cfg.command ??= {};
96
- cfg.command.usage ??= {
97
- description: "Show subscription usage/quota windows for configured providers",
98
- template: COMMAND_TEMPLATE,
99
- };
93
+ // `/usage` is owned by the TUI plugin so the slash command opens a local
94
+ // dialog instead of routing through the model. This hook's only job is to
95
+ // measure the assembled system prompt, which opencode never persists, so
96
+ // the TUI can split the "System & tools" residual (spec §9.1).
97
+ async "experimental.chat.system.transform"(input, output) {
98
+ try {
99
+ const sessionID = input?.sessionID;
100
+ if (typeof sessionID !== "string" || sessionID === "") return;
101
+ const model = input?.model;
102
+ if (model === undefined) return;
103
+ const system = Array.isArray(output?.system) ? output.system.join("\n") : "";
104
+ if (system === "") return;
105
+
106
+ await writeCapture({
107
+ version: 1,
108
+ sessionID,
109
+ providerID: model.providerID,
110
+ modelID: model.id,
111
+ systemChars: system.length,
112
+ systemTokens: estimateTokens(system),
113
+ capturedAt: Date.now(),
114
+ });
115
+ } catch {
116
+ // Best-effort: an experimental hook must never be able to break a request.
117
+ }
100
118
  },
101
119
 
102
120
  async event({ event }) {
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
  }