pi-provider-kimi-code 0.6.7 → 0.6.9

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/payload.ts CHANGED
@@ -2,11 +2,14 @@
2
2
  // per-protocol message transforms, OpenAI tool-call / tool-schema normalizers,
3
3
  // and the top-level applyKimiPayloadMutations that orchestrates them.
4
4
 
5
+ import { createHash } from "node:crypto";
6
+
5
7
  import type { CacheRetention, ThinkingLevel } from "@earendil-works/pi-ai";
6
8
  import type { KimiResolvedModelConfig, ModelReasoningEntry } from "./config.ts";
7
9
 
8
10
  import { getBaseUrl } from "./constants.ts";
9
11
  import { getKimiProviderHeaders } from "./device.ts";
12
+ import { refreshKimiAuthToken } from "./oauth.ts";
10
13
  import { optimizeToolSchemas } from "./schema-dedup.ts";
11
14
 
12
15
  // =============================================================================
@@ -30,6 +33,7 @@ export function resolveCacheRetention(value?: CacheRetention): CacheRetention {
30
33
  export interface KimiPayloadContext {
31
34
  api: "anthropic-messages" | "openai-completions";
32
35
  upload?: Uploader;
36
+ uploadCacheScope?: string;
33
37
  cacheKey?: string;
34
38
  cacheRetention: CacheRetention;
35
39
  reasoning?: ThinkingLevel;
@@ -80,6 +84,16 @@ function getUploadFilename(mimeType: string): string {
80
84
  "image/png": "upload.png",
81
85
  "image/gif": "upload.gif",
82
86
  "image/webp": "upload.webp",
87
+ // Video extensions mirror upstream kimi-code's MIME_TO_EXT
88
+ // (packages/kosong/src/providers/kimi-files.ts).
89
+ "video/mp4": "upload.mp4",
90
+ "video/mpeg": "upload.mpeg",
91
+ "video/quicktime": "upload.mov",
92
+ "video/webm": "upload.webm",
93
+ "video/x-matroska": "upload.mkv",
94
+ "video/x-msvideo": "upload.avi",
95
+ "video/x-flv": "upload.flv",
96
+ "video/3gpp": "upload.3gp",
83
97
  };
84
98
  return map[mimeType] ?? "upload.bin";
85
99
  }
@@ -88,22 +102,34 @@ function getUploadFilename(mimeType: string): string {
88
102
  // File upload (I/O edge)
89
103
  // =============================================================================
90
104
 
105
+ export interface UploadKimiFileDeps {
106
+ fetch?: typeof fetch;
107
+ refreshAccessToken?: (currentToken: string) => Promise<string | null>;
108
+ }
109
+
91
110
  export async function uploadKimiFile(
92
111
  apiKey: string,
93
112
  mimeType: string,
94
113
  data: string,
95
114
  thresholdBytes?: number,
115
+ deps?: UploadKimiFileDeps,
96
116
  ): Promise<string | null> {
117
+ const fetchImpl = deps?.fetch ?? fetch;
118
+ const refreshAccessToken = deps?.refreshAccessToken ?? refreshKimiAuthToken;
97
119
  const buffer = Buffer.from(data, "base64");
98
- if (!mimeType.startsWith("image/")) return null;
120
+ const isVideo = mimeType.startsWith("video/");
121
+ if (!mimeType.startsWith("image/") && !isVideo) return null;
99
122
  const threshold =
100
123
  thresholdBytes ?? parseInlineUploadThreshold(process.env.KIMI_CODE_UPLOAD_THRESHOLD_BYTES);
101
- if (buffer.length <= threshold) return null;
124
+ // The inline threshold applies to images only: the Kimi API has no inline
125
+ // video path (upstream kimi-code uploads every video via /files), so videos
126
+ // always upload.
127
+ if (!isVideo && buffer.length <= threshold) return null;
102
128
 
103
129
  const filename = getUploadFilename(mimeType);
104
130
  const formData = new FormData();
105
131
  formData.append("file", new Blob([buffer], { type: mimeType }), filename);
106
- formData.append("purpose", "image");
132
+ formData.append("purpose", isVideo ? "video" : "image");
107
133
 
108
134
  const uploadUrl = `${deriveFilesBaseUrl(getBaseUrl())}/files`;
109
135
  const debug = process.env.KIMI_CODE_DEBUG === "1";
@@ -113,13 +139,31 @@ export async function uploadKimiFile(
113
139
  );
114
140
  }
115
141
 
116
- try {
117
- const response = await fetch(uploadUrl, {
142
+ const postUpload = (token: string) =>
143
+ fetchImpl(uploadUrl, {
118
144
  method: "POST",
119
- headers: { ...getKimiProviderHeaders(), Authorization: `Bearer ${apiKey}` },
145
+ headers: { ...getKimiProviderHeaders(), Authorization: `Bearer ${token}` },
120
146
  body: formData,
121
147
  });
122
- if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
148
+
149
+ try {
150
+ let response = await postUpload(apiKey);
151
+ // Kimi access tokens are short-lived and invalidated server-side as soon
152
+ // as any peer process rotates them, so a 401 here usually means our key
153
+ // snapshot went stale mid-session, not that login is broken. Mirror the
154
+ // chat-stream recovery: force one refresh and retry once.
155
+ let responseText: string | undefined;
156
+ if (response.status === 401) {
157
+ responseText = await response.text();
158
+ const refreshed = await refreshAccessToken(apiKey);
159
+ if (refreshed && refreshed !== apiKey) {
160
+ console.error("[kimi-coding] upload got 401, retrying with refreshed token");
161
+ response = await postUpload(refreshed);
162
+ responseText = undefined;
163
+ }
164
+ }
165
+ if (!response.ok)
166
+ throw new Error(`${response.status} ${responseText ?? (await response.text())}`);
123
167
  const fileObj = (await response.json()) as { id?: string };
124
168
  if (!fileObj.id) throw new Error("missing file id");
125
169
  const fileUrl = `ms://${fileObj.id}`;
@@ -132,24 +176,69 @@ export async function uploadKimiFile(
132
176
  }
133
177
 
134
178
  // =============================================================================
135
- // Payload file transformers (pure given an Uploader)
179
+ // Payload file transformers
136
180
  //
137
181
  // These walk the provider-specific payload shape and replace inline base64
138
182
  // image blocks with ms:// references returned by the injected uploader. They
139
183
  // take an Uploader rather than an apiKey so they can be unit-tested with a
140
184
  // fake uploader; all network I/O stays behind that boundary.
185
+ //
186
+ // Successful uploads are remembered in a module-level cache shared across
187
+ // requests: payloads are rebuilt from session context every request, so
188
+ // without it a conversation's images would re-upload on every turn. Keys are
189
+ // content hashes rather than the data URLs themselves so the cache does not
190
+ // retain every image's base64 payload in memory. Failures are not cached and
191
+ // retry on the next request.
141
192
  // =============================================================================
142
193
 
143
- async function transformOpenAIPayloadFiles(payload: JsonRecord, upload: Uploader): Promise<void> {
194
+ const MAX_UPLOADED_FILE_CACHE_ENTRIES = 512;
195
+ const uploadedFileCache = new Map<string, string>();
196
+
197
+ function uploadedFileCacheKey(cacheScope: string, mimeType: string, data: string): string {
198
+ return createHash("sha256")
199
+ .update(cacheScope)
200
+ .update("\0")
201
+ .update(mimeType)
202
+ .update("\0")
203
+ .update(data)
204
+ .digest("hex");
205
+ }
206
+
207
+ function rememberUploadedFile(
208
+ uploadCache: Map<string, string>,
209
+ cacheKey: string,
210
+ url: string,
211
+ ): void {
212
+ if (
213
+ uploadCache === uploadedFileCache &&
214
+ !uploadCache.has(cacheKey) &&
215
+ uploadCache.size >= MAX_UPLOADED_FILE_CACHE_ENTRIES
216
+ ) {
217
+ const oldest = uploadCache.keys().next().value;
218
+ if (oldest !== undefined) uploadCache.delete(oldest);
219
+ }
220
+ uploadCache.set(cacheKey, url);
221
+ }
222
+
223
+ export function clearKimiUploadedFileCache(): void {
224
+ uploadedFileCache.clear();
225
+ }
226
+
227
+ async function transformOpenAIPayloadFiles(
228
+ payload: JsonRecord,
229
+ upload: Uploader,
230
+ uploadCache: Map<string, string>,
231
+ uploadCacheScope: string,
232
+ ): Promise<void> {
144
233
  if (!Array.isArray(payload.messages)) return;
145
- const cache = new Map<string, string>();
146
234
 
147
235
  for (const message of payload.messages) {
148
236
  if (!isRecord(message) || !Array.isArray(message.content)) continue;
149
237
 
150
238
  for (const block of message.content) {
151
239
  if (!isRecord(block)) continue;
152
- const key = block.type === "image_url" ? "image_url" : null;
240
+ const key =
241
+ block.type === "image_url" ? "image_url" : block.type === "video_url" ? "video_url" : null;
153
242
  if (!key) continue;
154
243
 
155
244
  const field = block[key];
@@ -164,9 +253,10 @@ async function transformOpenAIPayloadFiles(payload: JsonRecord, upload: Uploader
164
253
  const parsed = parseDataUrl(urlValue);
165
254
  if (!parsed) continue;
166
255
 
167
- const uploaded = cache.get(urlValue) ?? (await upload(parsed.mimeType, parsed.data));
256
+ const cacheKey = uploadedFileCacheKey(uploadCacheScope, parsed.mimeType, parsed.data);
257
+ const uploaded = uploadCache.get(cacheKey) ?? (await upload(parsed.mimeType, parsed.data));
168
258
  if (!uploaded) continue;
169
- cache.set(urlValue, uploaded);
259
+ rememberUploadedFile(uploadCache, cacheKey, uploaded);
170
260
 
171
261
  block[key] =
172
262
  typeof field === "string" ? uploaded : { ...(field as JsonRecord), url: uploaded };
@@ -330,9 +420,10 @@ function normalizeOpenAIToolSchemas(payload: JsonRecord): void {
330
420
  async function transformAnthropicPayloadFiles(
331
421
  payload: JsonRecord,
332
422
  upload: Uploader,
423
+ uploadCache: Map<string, string>,
424
+ uploadCacheScope: string,
333
425
  ): Promise<void> {
334
426
  if (!Array.isArray(payload.messages)) return;
335
- const cache = new Map<string, string>();
336
427
 
337
428
  const transformImageBlock = async (block: unknown): Promise<unknown> => {
338
429
  if (!isRecord(block) || block.type !== "image") return block;
@@ -342,10 +433,10 @@ async function transformAnthropicPayloadFiles(
342
433
  const data = source.data;
343
434
  if (typeof mediaType !== "string" || typeof data !== "string") return block;
344
435
 
345
- const cacheKey = `${mediaType}:${data}`;
346
- const uploaded = cache.get(cacheKey) ?? (await upload(mediaType, data));
436
+ const cacheKey = uploadedFileCacheKey(uploadCacheScope, mediaType, data);
437
+ const uploaded = uploadCache.get(cacheKey) ?? (await upload(mediaType, data));
347
438
  if (!uploaded) return block;
348
- cache.set(cacheKey, uploaded);
439
+ rememberUploadedFile(uploadCache, cacheKey, uploaded);
349
440
 
350
441
  const next: JsonRecord = { type: "image", source: { type: "url", url: uploaded } };
351
442
  if (block.cache_control !== undefined) next.cache_control = block.cache_control;
@@ -390,10 +481,12 @@ export async function applyKimiPayloadMutations(
390
481
 
391
482
  // 2. File upload dispatch (protocol-specific).
392
483
  if (ctx.upload) {
484
+ const uploadCache = ctx.uploadCacheScope ? uploadedFileCache : new Map<string, string>();
485
+ const uploadCacheScope = ctx.uploadCacheScope ?? "request";
393
486
  if (ctx.api === "openai-completions") {
394
- await transformOpenAIPayloadFiles(payload, ctx.upload);
487
+ await transformOpenAIPayloadFiles(payload, ctx.upload, uploadCache, uploadCacheScope);
395
488
  } else if (ctx.api === "anthropic-messages") {
396
- await transformAnthropicPayloadFiles(payload, ctx.upload);
489
+ await transformAnthropicPayloadFiles(payload, ctx.upload, uploadCache, uploadCacheScope);
397
490
  }
398
491
  }
399
492
  if (ctx.api === "openai-completions") {
@@ -447,8 +540,17 @@ export async function applyKimiPayloadMutations(
447
540
  }
448
541
 
449
542
  const generation = ctx.modelConfig.generation;
543
+ // Official kimi-code sends temperature/top_p only when explicitly configured
544
+ // (env KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P → generation.*); otherwise it
545
+ // omits them and lets the server apply its own defaults. Mirror that exactly:
546
+ // drop any value pi-ai seeded by default when the user hasn't configured one
547
+ // (the official client does not pin these to 1.0 / 0.95).
450
548
  if (generation.temperature !== undefined) payload.temperature = generation.temperature;
549
+ else delete payload.temperature;
451
550
  if (generation.topP !== undefined) payload.top_p = generation.topP;
551
+ else delete payload.top_p;
552
+ // Output cap: an explicit cap (KIMI_MODEL_MAX_COMPLETION_TOKENS / config →
553
+ // generation.maxCompletionTokens) is honored as a hardCap on the request value.
452
554
  if (generation.maxCompletionTokens !== undefined) {
453
555
  const maxTokensKey = ctx.api === "anthropic-messages" ? "max_tokens" : "max_completion_tokens";
454
556
  const currentMaxTokens = payload[maxTokensKey];
@@ -469,22 +571,39 @@ export async function applyKimiPayloadMutations(
469
571
  const mapped = resolveReasoningForLevel(resolvedReasoning, ctx.modelConfig);
470
572
  if (mapped) {
471
573
  const oldThinking = isRecord(payload.thinking) ? payload.thinking : {};
472
- const thinking: JsonRecord = {
473
- ...oldThinking,
474
- type: mapped.enabled ? "enabled" : "disabled",
475
- };
476
- delete thinking.effort;
477
- if (!mapped.enabled) delete thinking.keep;
478
574
  const effort = ctx.reasoning
479
575
  ? mapped.effort
480
576
  : (ctx.modelConfig.defaultEffort ?? mapped.effort);
481
- if (mapped.enabled && effort !== null && ctx.modelConfig.supportEfforts?.includes(effort)) {
482
- thinking.effort = effort;
483
- }
484
- if (mapped.enabled && ctx.modelConfig.thinkingKeep) {
485
- thinking.keep = ctx.modelConfig.thinkingKeep;
577
+ const effortSupported =
578
+ effort !== null && ctx.modelConfig.supportEfforts?.includes(effort) === true;
579
+ if (ctx.api === "anthropic-messages" && oldThinking.type === "adaptive") {
580
+ // pi-ai >=0.82 builds adaptive thinking for models carrying
581
+ // compat.forceAdaptiveThinking (streamSimpleKimi sets it on the
582
+ // anthropic runtime model). Keep the adaptive shape — effort lives in
583
+ // top-level output_config there, not inside thinking — and only
584
+ // replace it with an explicit disable when the level maps to off.
585
+ if (mapped.enabled) {
586
+ if (effortSupported) payload.output_config = { effort };
587
+ else delete payload.output_config;
588
+ } else {
589
+ payload.thinking = { type: "disabled" };
590
+ delete payload.output_config;
591
+ }
592
+ } else {
593
+ const thinking: JsonRecord = {
594
+ ...oldThinking,
595
+ type: mapped.enabled ? "enabled" : "disabled",
596
+ };
597
+ delete thinking.effort;
598
+ if (!mapped.enabled) delete thinking.keep;
599
+ if (mapped.enabled && effortSupported) {
600
+ thinking.effort = effort;
601
+ }
602
+ if (mapped.enabled && ctx.modelConfig.thinkingKeep) {
603
+ thinking.keep = ctx.modelConfig.thinkingKeep;
604
+ }
605
+ payload.thinking = thinking;
486
606
  }
487
- payload.thinking = thinking;
488
607
  }
489
608
  }
490
609
 
@@ -510,15 +629,10 @@ export async function applyKimiPayloadMutations(
510
629
  }
511
630
  }
512
631
 
513
- // 8. K2.7 Code API constraints: the server rejects non-default values for
514
- // temperature (must be 1.0) and top_p (must be 0.95), and tool_choice
515
- // "required" / function-specific when thinking is enabled (always-on).
516
- if (payload.temperature !== undefined && payload.temperature !== 1) {
517
- delete payload.temperature;
518
- }
519
- if (payload.top_p !== undefined && payload.top_p !== 0.95) {
520
- delete payload.top_p;
521
- }
632
+ // 8. K2.7 Code API constraints: the server rejects tool_choice "required" /
633
+ // function-specific when thinking is enabled (always-on). temperature/top_p
634
+ // are handled in step 6 — omitted unless explicitly configured, matching the
635
+ // official kimi-code client rather than pinned to 1.0/0.95.
522
636
  if (payload.tool_choice !== undefined) {
523
637
  const tc = payload.tool_choice;
524
638
  const isAllowed =
package/src/stream.ts CHANGED
@@ -58,9 +58,13 @@ import {
58
58
  type KimiResolvedModelConfig,
59
59
  } from "./config.ts";
60
60
 
61
- import { ENV_KIMI_CODE_PROTOCOL, getApiProtocol, getBaseUrl } from "./constants.ts";
61
+ import { ENV_KIMI_CODE_PROTOCOL, PROVIDER_ID, getApiProtocol, getBaseUrl } from "./constants.ts";
62
62
  import { getKimiProviderHeaders } from "./device.ts";
63
- import { isKimiAuthErrorMessage, refreshKimiAuthToken } from "./oauth.ts";
63
+ import {
64
+ isKimiAuthErrorMessage,
65
+ readStoredOAuthCredential,
66
+ refreshKimiAuthToken,
67
+ } from "./oauth.ts";
64
68
  import {
65
69
  type Uploader,
66
70
  applyKimiPayloadMutations,
@@ -190,11 +194,21 @@ export async function* filterEmptyResponseStream(
190
194
 
191
195
  const KIMI_API_KEY_ENV_REFERENCES = new Set(["$KIMI_API_KEY", "${KIMI_API_KEY}"]);
192
196
 
197
+ // On the normal request path pi resolves the provider credential through its
198
+ // ModelRuntime and hands it to us. Callers that arrive through pi-ai's global
199
+ // api registry (a bare agentLoop() with no streamFn) have no ModelRuntime
200
+ // behind them and pass nothing, so read the same credential store pi itself
201
+ // would have read instead of sending an unauthenticated request. Kept last so
202
+ // an explicit key and the environment override keep their existing precedence.
203
+ function getStoredKimiAccessToken(): string {
204
+ return readStoredOAuthCredential(PROVIDER_ID)?.access?.trim() || "";
205
+ }
206
+
193
207
  export function resolveKimiApiKey(apiKey: string | undefined): string {
194
208
  if (apiKey !== undefined && KIMI_API_KEY_ENV_REFERENCES.has(apiKey)) {
195
- return process.env.KIMI_API_KEY?.trim() || "";
209
+ return process.env.KIMI_API_KEY?.trim() || getStoredKimiAccessToken();
196
210
  }
197
- return apiKey || process.env.KIMI_API_KEY || "";
211
+ return apiKey || process.env.KIMI_API_KEY || getStoredKimiAccessToken();
198
212
  }
199
213
 
200
214
  // SimpleStreamOptions.headers is Record<string, string> on pi <=0.79 and
@@ -285,6 +299,7 @@ export function streamSimpleKimi(
285
299
  await applyKimiPayloadMutations(nextPayload, {
286
300
  api: apiProtocol,
287
301
  upload,
302
+ uploadCacheScope: apiKey ? `${getBaseUrl(wireProtocol)}:${apiKey}` : undefined,
288
303
  cacheKey,
289
304
  cacheRetention,
290
305
  reasoning: options?.reasoning,
@@ -323,6 +338,19 @@ export function streamSimpleKimi(
323
338
  ...model,
324
339
  api: apiProtocol,
325
340
  baseUrl: getBaseUrl(wireProtocol),
341
+ // Kimi's anthropic-protocol endpoint speaks adaptive thinking and
342
+ // rejects/ignores the interleaved-thinking beta header; pi-ai >=0.82
343
+ // keys both behaviors off these compat flags (its own built-in
344
+ // kimi-coding models carry them in generated metadata).
345
+ ...(wireProtocol === "anthropic"
346
+ ? {
347
+ compat: {
348
+ ...(model as { compat?: Record<string, unknown> }).compat,
349
+ forceAdaptiveThinking: true,
350
+ allowEmptySignature: true,
351
+ },
352
+ }
353
+ : {}),
326
354
  } as Model<Api>;
327
355
  const upstream =
328
356
  wireProtocol === "openai"
package/src/usage.ts CHANGED
@@ -2,15 +2,6 @@ import { PROVIDER_ID, getBaseUrl } from "./constants.ts";
2
2
  import { getKimiProviderHeaders } from "./device.ts";
3
3
  import { readStoredOAuthCredential, refreshKimiAuthToken } from "./oauth.ts";
4
4
 
5
- const MEMBERSHIP_LEVEL_NAMES: Record<string, string> = {
6
- LEVEL_FREE: "Free",
7
- LEVEL_BASIC: "Adagio",
8
- LEVEL_STANDARD: "Moderato",
9
- LEVEL_INTERMEDIATE: "Allegretto",
10
- LEVEL_ADVANCED: "Allegro",
11
- LEVEL_PREMIUM: "Vivace",
12
- };
13
-
14
5
  const RESET_TIME_KEYS = [
15
6
  "resetTime",
16
7
  "reset_time",
@@ -36,7 +27,6 @@ export interface UsageFormatOptions {
36
27
 
37
28
  export interface KimiUsageSnapshot {
38
29
  summary: string;
39
- membershipLevel: string | null;
40
30
  }
41
31
 
42
32
  interface BoosterWalletInfo {
@@ -174,9 +164,6 @@ export function parseUsageSummary(payload: unknown, options: UsageFormatOptions
174
164
 
175
165
  const record = payload as Record<string, unknown>;
176
166
  const lines: string[] = [];
177
- const membership = parseMembership(record);
178
- if (membership) lines.push(membership, "");
179
-
180
167
  const summary = parseUsageRow(record.usage, "Current week");
181
168
  if (summary)
182
169
  lines.push(formatUsageRow({ ...summary, label: normalizeUsageLabel(summary.label) }, options));
@@ -235,24 +222,6 @@ function formatWindowLabel(value: unknown, fallbackLabel: string): string {
235
222
  return `Current ${minutes}m window`;
236
223
  }
237
224
 
238
- export function parseMembershipLevel(record: Record<string, unknown>): string | null {
239
- const user = record.user;
240
- if (typeof user !== "object" || user === null || Array.isArray(user)) return null;
241
- const membership = (user as Record<string, unknown>).membership;
242
- if (typeof membership !== "object" || membership === null || Array.isArray(membership)) {
243
- return null;
244
- }
245
- const level = (membership as Record<string, unknown>).level;
246
- return typeof level === "string" && level ? level : null;
247
- }
248
-
249
- export function parseMembership(record: Record<string, unknown>): string | null {
250
- const level = parseMembershipLevel(record);
251
- if (!level) return null;
252
- const name = MEMBERSHIP_LEVEL_NAMES[level];
253
- return name ? `Membership: ${name} (${level})` : `Membership: ${level}`;
254
- }
255
-
256
225
  export function formatUsageRow(row: UsageRow, options: UsageFormatOptions = {}): string {
257
226
  if (row.limit <= 0) return `${row.label}\n${row.used} used`;
258
227
  const used = Math.max(0, Math.min(row.used, row.limit));
@@ -356,10 +325,7 @@ export async function fetchKimiUsageSnapshot(
356
325
  ): Promise<KimiUsageSnapshot> {
357
326
  const token = options.token ?? getKimiUsageToken();
358
327
  if (!token) {
359
- return {
360
- summary: "Usage: missing credentials. Run /login kimi-coding.",
361
- membershipLevel: null,
362
- };
328
+ return { summary: "Usage: missing credentials. Run /login kimi-coding." };
363
329
  }
364
330
 
365
331
  const controller = new AbortController();
@@ -371,17 +337,13 @@ export async function fetchKimiUsageSnapshot(
371
337
  if (refreshed) response = await fetchKimiUsage(refreshed, controller.signal);
372
338
  }
373
339
  if (!response.ok) {
374
- return { summary: `Usage: fetch failed (${response.status})`, membershipLevel: null };
340
+ return { summary: `Usage: fetch failed (${response.status})` };
375
341
  }
376
342
  const payload = (await response.json()) as unknown;
377
- const membershipLevel =
378
- typeof payload === "object" && payload !== null && !Array.isArray(payload)
379
- ? parseMembershipLevel(payload as Record<string, unknown>)
380
- : null;
381
- return { summary: parseUsageSummary(payload), membershipLevel };
343
+ return { summary: parseUsageSummary(payload) };
382
344
  } catch (error) {
383
345
  const message = error instanceof Error ? error.message : String(error);
384
- return { summary: `Usage: fetch failed (${message})`, membershipLevel: null };
346
+ return { summary: `Usage: fetch failed (${message})` };
385
347
  } finally {
386
348
  clearTimeout(timeout);
387
349
  }