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/README.md +10 -1
- package/package.json +1 -2
- package/src/context/collect.ts +12 -53
- package/src/context/estimate.ts +2 -27
- package/src/context/format.ts +6 -38
- package/src/context/grid.ts +4 -18
- package/src/context/headroom.ts +1 -4
- package/src/context/system.ts +2 -34
- package/src/context/types.ts +0 -1
- package/src/fallback.ts +1 -6
- package/src/index.ts +172 -16
- package/src/normalize.ts +17 -27
- package/src/providers/chatgpt.ts +24 -78
- package/src/providers/copilot.ts +28 -86
- package/src/providers/http.ts +90 -0
- package/src/providers/kimi.ts +47 -59
- package/src/providers/opencode-go.ts +33 -89
- package/src/quota-lines.ts +4 -21
- package/src/render.ts +11 -14
- package/src/tui-format.ts +12 -0
- package/src/tui.tsx +4 -7
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
|
-
/**
|
|
49
|
-
export function
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
/**
|
|
55
|
-
export function
|
|
56
|
-
|
|
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
|
}
|
package/src/providers/chatgpt.ts
CHANGED
|
@@ -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,
|
|
134
|
-
|
|
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);
|
package/src/providers/copilot.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/providers/kimi.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
):
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (res.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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(
|
|
197
|
+
return parse(json as Record<string, unknown>, cred);
|
|
210
198
|
},
|
|
211
199
|
};
|
|
212
200
|
}
|