pi-spark 0.19.3 → 0.21.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.
Files changed (38) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +58 -6
  3. package/assets/screencast-write.mp4 +0 -0
  4. package/index.ts +2 -0
  5. package/package.json +14 -9
  6. package/src/config/schema.ts +2 -0
  7. package/src/features/credits/config.ts +18 -1
  8. package/src/features/credits/index.ts +7 -4
  9. package/src/features/credits/manager.ts +7 -4
  10. package/src/features/credits/providers/deepseek.ts +2 -9
  11. package/src/features/credits/providers/index.ts +10 -4
  12. package/src/features/credits/providers/kimi-code.ts +61 -25
  13. package/src/features/credits/providers/moonshot.ts +3 -10
  14. package/src/features/credits/providers/openai-codex.ts +24 -45
  15. package/src/features/credits/providers/openrouter.ts +2 -9
  16. package/src/features/credits/providers/vercel-ai-gateway.ts +2 -9
  17. package/src/features/credits/types.ts +2 -2
  18. package/src/features/editor/config.ts +6 -1
  19. package/src/features/editor/index.ts +21 -4
  20. package/src/features/editor/spinner.ts +2 -7
  21. package/src/features/footer/config.ts +7 -1
  22. package/src/features/footer/index.ts +19 -8
  23. package/src/features/fullscreen/filler.ts +67 -23
  24. package/src/features/fullscreen/index.ts +12 -8
  25. package/src/features/presets/config.ts +2 -1
  26. package/src/features/presets/manager.ts +1 -1
  27. package/src/features/recap/index.ts +1 -1
  28. package/src/features/title/index.ts +8 -3
  29. package/src/features/title/manager.ts +2 -3
  30. package/src/features/write/config.ts +3 -0
  31. package/src/features/write/index.ts +177 -0
  32. package/src/features/write/path.ts +33 -0
  33. package/src/utils/format.ts +3 -4
  34. package/src/utils/http.ts +16 -0
  35. package/src/utils/usage.ts +2 -0
  36. package/themes/github-dark-default.json +91 -0
  37. package/themes/github-light-default.json +91 -0
  38. package/LICENSE +0 -21
package/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2026-present Zilong Liang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -69,13 +69,19 @@ pi-spark generates a short recap of the current session after it goes idle, or o
69
69
 
70
70
  ### Title
71
71
 
72
- pi-spark names the session automatically after the first exchange, so it's easy to find later in the session selector.
72
+ pi-spark names the session automatically after the first completed turn, so it's easy to find later in the session selector.
73
73
 
74
- - After the first message is sent and the agent replies, pi-spark generates a short title from the conversation and sets it as the session name.
74
+ - Once the agent completes its first response and any tool calls from that turn, pi-spark generates a short title from the conversation and sets it as the session name.
75
75
  - Generation is silent: nothing is shown in the UI, and the usage is recorded in a session entry like the recap.
76
76
  - Sessions that already have a name are left untouched.
77
77
  - The title generation can use its own model, configured separately from your working model.
78
78
 
79
+ ### Scrolling write previews
80
+
81
+ Long, streaming `write` tool calls keep their latest lines visible instead of pinning the preview to the beginning. The omitted-line count appears above the preview, while `ctrl+o` still expands the full content.
82
+
83
+ https://raw.githubusercontent.com/zlliang/pi-spark/refs/heads/main/assets/screencast-write.mp4
84
+
79
85
  ## Configuration
80
86
 
81
87
  pi-spark reads config from `~/.pi/agent/spark.json` and from the current project's `.pi/spark.json`. Project config overrides matching global fields.
@@ -120,17 +126,37 @@ All fields are optional. Each top-level feature runs with the defaults below unl
120
126
 
121
127
  | Field | Value (or `false`) | Description |
122
128
  | --- | --- | --- |
123
- | `credits` | `{}` | Shows the active provider's credit balance or rate-limit usage in the status line. |
129
+ | `credits` | `CreditsConfig` | Shows the active provider's credit balance or rate-limit usage in the status line. |
124
130
  | `editor` | `EditorConfig` | Shows a working indicator and the current model on the editor's top border. |
125
- | `footer` | `{}` | Shows session info, extension statuses, cost, and context usage on one line. |
131
+ | `footer` | `FooterConfig` | Shows session info, extension statuses, cost, and context usage. |
126
132
  | `fullscreen` | `{}` | Clears the screen and scrollback on start and exit, and pins the editor and footer to the bottom. |
127
133
  | `presets` | `{ [name]: Preset }` | Defines named model presets, keyed by name. |
128
134
  | `recap` | `RecapConfig` | Generates a session recap when idle or on demand. |
129
- | `title` | `TitleConfig` | Names the session automatically after the first exchange. |
135
+ | `title` | `TitleConfig` | Names the session automatically after the first completed turn. |
136
+ | `write` | `{}` | Keeps the latest lines of streaming `write` tool calls visible. |
137
+
138
+ #### `CreditsConfig`
139
+
140
+ All supported providers are enabled by default. Set a provider to `false` to disable its credits status, or to `true` to override a global `false` in project config.
141
+
142
+ ```json
143
+ {
144
+ "credits": {
145
+ "providers": {
146
+ "fireworks": false,
147
+ "openai-codex": true
148
+ }
149
+ }
150
+ }
151
+ ```
152
+
153
+ | Field | Value | Description |
154
+ | --- | --- | --- |
155
+ | `providers` | partial map of provider IDs to booleans | Enables or disables credits for individual providers. Valid IDs: `deepseek`, `fireworks`, `kimi-coding`, `moonshotai`, `moonshotai-cn`, `openai-codex`, `openrouter`, and `vercel-ai-gateway`. |
130
156
 
131
157
  #### `EditorConfig`
132
158
 
133
- The `spinner` field is optional and defaults to `tildes`.
159
+ The `spinner` field is optional and defaults to `tildes`. `thinkingLevelIndicator` is also optional and defaults to `border`.
134
160
 
135
161
  | Field | Value | Description |
136
162
  | --- | --- | --- |
@@ -138,6 +164,17 @@ The `spinner` field is optional and defaults to `tildes`.
138
164
  | | `lights` | `○, ●` |
139
165
  | | `tildes` (default) | `∼, ≈, ≋, ≈, ∼` |
140
166
  | | `pulse` | `·, •, ●, •, ·` |
167
+ | `thinkingLevelIndicator` | `border` (default) | Uses the thinking-level color for the editor border. |
168
+ | | `model` | Uses the thinking-level color for the model name and the dim color for the editor border. |
169
+
170
+ #### `FooterConfig`
171
+
172
+ The `statusPosition` field is optional and defaults to `inline`.
173
+
174
+ | Field | Value | Description |
175
+ | --- | --- | --- |
176
+ | `statusPosition` | `inline` (default) | Shows extension statuses on the same line as session info, cost, and context usage. |
177
+ | | `below` | Moves extension statuses to a new line below. |
141
178
 
142
179
  #### `Preset`
143
180
 
@@ -185,3 +222,18 @@ For example, to disable the customized footer:
185
222
  "footer": false
186
223
  }
187
224
  ```
225
+
226
+ ## Themes
227
+
228
+ pi-spark ships with two custom themes inspired by [GitHub's VS Code themes](https://github.com/primer/github-vscode-theme):
229
+
230
+ - [`github-light-default`](themes/github-light-default.json)
231
+ - [`github-dark-default`](themes/github-dark-default.json)
232
+
233
+ Select either theme in `/settings`, or configure automatic switching between them:
234
+
235
+ ```json
236
+ {
237
+ "theme": "github-light-default/github-dark-default"
238
+ }
239
+ ```
Binary file
package/index.ts CHANGED
@@ -6,6 +6,7 @@ import { registerFullscreen } from "./src/features/fullscreen";
6
6
  import { registerPresets } from "./src/features/presets";
7
7
  import { registerRecap } from "./src/features/recap";
8
8
  import { registerTitle } from "./src/features/title";
9
+ import { registerWrite } from "./src/features/write";
9
10
 
10
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
12
 
@@ -26,4 +27,5 @@ export default function (pi: ExtensionAPI) {
26
27
  registerPresets(pi);
27
28
  registerRecap(pi);
28
29
  registerTitle(pi);
30
+ registerWrite(pi);
29
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.19.3",
3
+ "version": "0.21.0",
4
4
  "description": "Pi package that polishes your daily experience.",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
@@ -15,6 +15,7 @@
15
15
  "files": [
16
16
  "index.ts",
17
17
  "src",
18
+ "themes",
18
19
  "assets",
19
20
  "docs",
20
21
  "README.md",
@@ -24,6 +25,9 @@
24
25
  "extensions": [
25
26
  "./index.ts"
26
27
  ],
28
+ "themes": [
29
+ "./themes"
30
+ ],
27
31
  "image": "https://raw.githubusercontent.com/zlliang/pi-spark/main/assets/cover.png"
28
32
  },
29
33
  "scripts": {
@@ -33,22 +37,23 @@
33
37
  "@grpc/grpc-js": "^1.14.4",
34
38
  "@grpc/proto-loader": "^0.8.1",
35
39
  "defu": "^6.1.7",
40
+ "ky": "^2.0.2",
36
41
  "parse-duration": "^2.1.8",
37
42
  "pretty-ms": "^9.3.0",
38
43
  "zod": "^4.4.3"
39
44
  },
40
45
  "peerDependencies": {
41
- "@earendil-works/pi-agent-core": ">=0.80.8",
42
- "@earendil-works/pi-ai": ">=0.80.8",
43
- "@earendil-works/pi-coding-agent": ">=0.80.8",
44
- "@earendil-works/pi-tui": ">=0.80.8",
46
+ "@earendil-works/pi-agent-core": ">=0.81.0",
47
+ "@earendil-works/pi-ai": ">=0.81.0",
48
+ "@earendil-works/pi-coding-agent": ">=0.81.0",
49
+ "@earendil-works/pi-tui": ">=0.81.0",
45
50
  "typebox": "*"
46
51
  },
47
52
  "devDependencies": {
48
- "@earendil-works/pi-agent-core": ">=0.80.8",
49
- "@earendil-works/pi-ai": ">=0.80.8",
50
- "@earendil-works/pi-coding-agent": ">=0.80.8",
51
- "@earendil-works/pi-tui": ">=0.80.8",
53
+ "@earendil-works/pi-agent-core": ">=0.81.0",
54
+ "@earendil-works/pi-ai": ">=0.81.0",
55
+ "@earendil-works/pi-coding-agent": ">=0.81.0",
56
+ "@earendil-works/pi-tui": ">=0.81.0",
52
57
  "@types/node": "*",
53
58
  "typebox": "*",
54
59
  "typescript": "^7.0.2"
@@ -7,6 +7,7 @@ import { fullscreenConfigSchema } from "../features/fullscreen/config";
7
7
  import { presetsConfigSchema } from "../features/presets/config";
8
8
  import { recapConfigSchema } from "../features/recap/config";
9
9
  import { titleConfigSchema } from "../features/title/config";
10
+ import { writeConfigSchema } from "../features/write/config";
10
11
 
11
12
  /**
12
13
  * Raw option shape for each feature. The enable/disable/default policy lives in `loadConfig`:
@@ -21,6 +22,7 @@ export const featureSchemas = {
21
22
  presets: presetsConfigSchema,
22
23
  recap: recapConfigSchema,
23
24
  title: titleConfigSchema,
25
+ write: writeConfigSchema,
24
26
  } as const;
25
27
 
26
28
  /** Resolved config for every feature; `false` means the feature is disabled. */
@@ -1,3 +1,20 @@
1
1
  import * as z from "zod";
2
2
 
3
- export const creditsConfigSchema = z.object({});
3
+ const creditsProviderIdSchema = z.enum([
4
+ "deepseek",
5
+ "fireworks",
6
+ "kimi-coding",
7
+ "moonshotai",
8
+ "moonshotai-cn",
9
+ "openai-codex",
10
+ "openrouter",
11
+ "vercel-ai-gateway",
12
+ ]);
13
+
14
+ export type CreditsProviderId = z.infer<typeof creditsProviderIdSchema>;
15
+
16
+ export const creditsConfigSchema = z.object({
17
+ providers: z.partialRecord(creditsProviderIdSchema, z.boolean()).optional(),
18
+ });
19
+
20
+ export type CreditsConfig = z.infer<typeof creditsConfigSchema>;
@@ -1,5 +1,5 @@
1
1
  import { CreditsManager } from "./manager";
2
- import { registerProviderExtensions } from "./providers";
2
+ import { getEnabledProviders, registerProviderExtensions } from "./providers";
3
3
  import { loadConfig } from "../../config";
4
4
  import { isUsage } from "../../utils/usage";
5
5
 
@@ -18,10 +18,13 @@ export function registerCredits(pi: ExtensionAPI): void {
18
18
 
19
19
  pi.on("session_start", (_event, ctx) => {
20
20
  const config = loadConfig(ctx).credits;
21
- if (!ctx.hasUI || !config) return;
21
+ if (ctx.mode !== "tui" || !config) return;
22
22
 
23
- creditsManager = new CreditsManager();
24
- registerProviderExtensions(pi, ctx, async (ctx) => await creditsManager?.refresh(ctx));
23
+ const providers = getEnabledProviders(config);
24
+ if (providers.length === 0) return;
25
+
26
+ creditsManager = new CreditsManager(providers);
27
+ registerProviderExtensions(pi, ctx, providers, async (ctx) => await creditsManager?.refresh(ctx));
25
28
 
26
29
  creditsManager.refresh(ctx);
27
30
  });
@@ -5,16 +5,20 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import type { CreditsProvider } from "./types";
6
6
 
7
7
  const STATUS_KEY = "credits";
8
- const REQUEST_TIMEOUT_MS = 30_000;
9
8
 
10
9
  export class CreditsManager {
11
10
  private inflight: AbortController | undefined = undefined;
11
+ private providers: CreditsProvider[];
12
12
  private currentProvider: string | undefined = undefined;
13
13
 
14
+ constructor(providers: CreditsProvider[]) {
15
+ this.providers = providers;
16
+ }
17
+
14
18
  async refresh(ctx: ExtensionContext): Promise<void> {
15
19
  this.inflight?.abort();
16
20
 
17
- const provider = findProvider(ctx.model?.provider);
21
+ const provider = findProvider(this.providers, ctx.model?.provider);
18
22
  if (!provider) {
19
23
  this.inflight = undefined;
20
24
  this.currentProvider = undefined;
@@ -50,8 +54,7 @@ export class CreditsManager {
50
54
  return;
51
55
  }
52
56
 
53
- const signals = AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]);
54
- const credits = await provider.fetch(apiKey, signals);
57
+ const credits = await provider.fetch(apiKey, signal);
55
58
 
56
59
  // The active model may have changed while the request was in flight.
57
60
  if (ctx.model?.provider !== provider.id) return;
@@ -1,4 +1,5 @@
1
1
  import { convertToUSD, toNumber } from "../../../utils/format";
2
+ import { http, withAuth } from "../../../utils/http";
2
3
 
3
4
  import type { Credits, CreditsProvider } from "../types";
4
5
 
@@ -19,15 +20,7 @@ export const deepseekProvider: CreditsProvider = {
19
20
  label: "DeepSeek",
20
21
 
21
22
  async fetch(apiKey, signal): Promise<Credits> {
22
- const headers: Record<string, string> = {
23
- Accept: "application/json",
24
- Authorization: `Bearer ${apiKey}`,
25
- };
26
-
27
- const response = await fetch(URL, { headers, signal });
28
- if (!response.ok) throw new Error("request failed");
29
-
30
- const payload = (await response.json()) as DeepSeekBalanceResponse;
23
+ const payload = await withAuth(http, apiKey).get(URL, { signal }).json<DeepSeekBalanceResponse>();
31
24
  const balance = payload.balance_infos?.find((entry) => entry.currency === "USD") ?? payload.balance_infos?.[0];
32
25
  const remaining = await convertToUSD(toNumber(balance?.total_balance), balance?.currency, signal);
33
26
 
@@ -7,6 +7,7 @@ import { openrouterProvider } from "./openrouter";
7
7
  import { vercelAiGatewayProvider } from "./vercel-ai-gateway";
8
8
 
9
9
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type { CreditsConfig } from "../config";
10
11
  import type { CreditsProvider, RefreshCredits } from "../types";
11
12
 
12
13
  const PROVIDERS: CreditsProvider[] = [
@@ -20,10 +21,15 @@ const PROVIDERS: CreditsProvider[] = [
20
21
  vercelAiGatewayProvider,
21
22
  ];
22
23
 
23
- export function findProvider(provider?: string): CreditsProvider | undefined {
24
- return PROVIDERS.find((entry) => entry.id === provider);
24
+ export function getEnabledProviders(config: CreditsConfig): CreditsProvider[] {
25
+ const settings = config.providers as Partial<Record<string, boolean>> | undefined;
26
+ return PROVIDERS.filter((provider) => settings?.[provider.id] !== false);
25
27
  }
26
28
 
27
- export function registerProviderExtensions(pi: ExtensionAPI, ctx: ExtensionContext, refresh: RefreshCredits): void {
28
- for (const provider of PROVIDERS) provider.register?.(pi, ctx, refresh);
29
+ export function findProvider(providers: CreditsProvider[], provider?: string): CreditsProvider | undefined {
30
+ return providers.find((entry) => entry.id === provider);
31
+ }
32
+
33
+ export function registerProviderExtensions(pi: ExtensionAPI, ctx: ExtensionContext, providers: CreditsProvider[], refresh: RefreshCredits): void {
34
+ for (const provider of providers) provider.register?.(pi, ctx, refresh);
29
35
  }
@@ -1,21 +1,49 @@
1
1
  import { toNumber } from "../../../utils/format";
2
+ import { http, withAuth } from "../../../utils/http";
2
3
 
3
4
  import type { Credits, CreditsLane, CreditsProvider } from "../types";
4
5
 
5
6
  const PROVIDER = "kimi-coding";
6
7
  const URL = "https://api.kimi.com/coding/v1/usages";
8
+ const BOOSTER_FIXED_POINT_CENTS = 1_000_000;
9
+
10
+ interface KimiCodeUsage {
11
+ limit?: string | number;
12
+ used?: string | number;
13
+ remaining?: string | number;
14
+ }
15
+
16
+ interface KimiCodeLimit {
17
+ window?: { duration?: number; timeUnit?: string } | null;
18
+ detail?: KimiCodeUsage | null;
19
+ }
20
+
21
+ interface KimiCodeBoosterWallet {
22
+ balance?: {
23
+ type?: string;
24
+ amount?: string | number;
25
+ amountLeft?: string | number;
26
+ } | null;
27
+ }
7
28
 
8
29
  interface KimiCodeUsageResponse {
9
- usage?: { used?: string | number } | null;
10
- limits?: {
11
- window?: { duration?: number; timeUnit?: string } | null;
12
- detail?: { used?: string | number } | null;
13
- }[] | null;
30
+ usage?: KimiCodeUsage | null;
31
+ limits?: KimiCodeLimit[] | null;
32
+ boosterWallet?: KimiCodeBoosterWallet | null;
14
33
  }
15
34
 
16
- function toPercent(used: number | undefined): number | undefined {
17
- if (used === undefined) return undefined;
18
- return Math.min(100, Math.max(0, used));
35
+ function toPercent(detail: KimiCodeUsage): number | undefined {
36
+ const limit = toNumber(detail.limit);
37
+ let used = toNumber(detail.used);
38
+
39
+ if (used === undefined && limit !== undefined) {
40
+ const remaining = toNumber(detail.remaining);
41
+ if (remaining !== undefined) used = limit - remaining;
42
+ }
43
+
44
+ if (used === undefined || limit === undefined || limit <= 0) return undefined;
45
+
46
+ return Math.min(100, Math.max(0, (used / limit) * 100));
19
47
  }
20
48
 
21
49
  function formatWindowLabel(window?: { duration?: number; timeUnit?: string } | null): string {
@@ -34,8 +62,20 @@ function formatWindowLabel(window?: { duration?: number; timeUnit?: string } | n
34
62
  return `${duration}${unit.replace("TIME_UNIT_", "").toLowerCase()}`;
35
63
  }
36
64
 
37
- function buildLane(label: string, detail?: KimiCodeUsageResponse["usage"]): CreditsLane {
38
- return { label, percent: toPercent(toNumber(detail?.used ?? 0)) };
65
+ function buildLane(label: string, detail: KimiCodeUsage): CreditsLane | undefined {
66
+ const percent = toPercent(detail);
67
+ return percent === undefined ? undefined : { label, percent };
68
+ }
69
+
70
+ function formatSuffix(wallet?: KimiCodeBoosterWallet | null): string | undefined {
71
+ if (wallet?.balance?.type !== "BOOSTER") return undefined;
72
+
73
+ const amount = toNumber(wallet.balance.amount);
74
+ if (amount === undefined || amount <= 0) return undefined;
75
+
76
+ const amountLeft = Math.max(0, toNumber(wallet.balance.amountLeft) ?? 0);
77
+ const remaining = Math.round(amountLeft / BOOSTER_FIXED_POINT_CENTS) / 100;
78
+ return `(Extra Usage $${remaining.toFixed(2)})`;
39
79
  }
40
80
 
41
81
  export const kimiCodeProvider: CreditsProvider = {
@@ -43,27 +83,23 @@ export const kimiCodeProvider: CreditsProvider = {
43
83
  label: "Kimi Code",
44
84
 
45
85
  async fetch(apiKey, signal): Promise<Credits> {
46
- const headers: Record<string, string> = {
47
- Accept: "application/json",
48
- Authorization: `Bearer ${apiKey}`,
49
- };
50
-
51
- const response = await fetch(URL, { headers, signal });
52
- if (!response.ok) throw new Error("request failed");
53
-
54
- const payload = (await response.json()) as KimiCodeUsageResponse;
86
+ const payload = await withAuth(http, apiKey).get(URL, { signal }).json<KimiCodeUsageResponse>();
55
87
  const lanes: CreditsLane[] = [];
56
88
 
57
89
  payload.limits?.forEach((item) => {
58
- if (!item) return;
90
+ if (!item.detail) return;
59
91
 
60
- const label = formatWindowLabel(item.window);
61
- lanes.push(buildLane(label, item.detail));
92
+ const lane = buildLane(formatWindowLabel(item.window), item.detail);
93
+ if (lane) lanes.push(lane);
62
94
  });
63
95
 
64
- const weeklyLane = buildLane("7d", payload.usage);
65
- lanes.push(weeklyLane);
96
+ if (payload.usage) {
97
+ const weeklyLane = buildLane("7d", payload.usage);
98
+ if (weeklyLane) lanes.push(weeklyLane);
99
+ }
100
+
101
+ if (lanes.length === 0) throw new Error("no usage data");
66
102
 
67
- return { type: "windows", lanes };
103
+ return { type: "windows", lanes, suffix: formatSuffix(payload.boosterWallet) };
68
104
  },
69
105
  };
@@ -1,4 +1,5 @@
1
1
  import { convertToUSD, toNumber } from "../../../utils/format";
2
+ import { http, withAuth } from "../../../utils/http";
2
3
 
3
4
  import type { Credits, CreditsProvider } from "../types";
4
5
 
@@ -13,21 +14,13 @@ interface MoonshotBalanceResponse {
13
14
  * different currencies (USD vs CNY), which the endpoint does not report, so each Pi provider ID
14
15
  * fixes both host and currency.
15
16
  */
16
- function createMoonshotProvider(id: string, host: string, currency: string): CreditsProvider {
17
+ function createMoonshotProvider(id: "moonshotai" | "moonshotai-cn", host: string, currency: string): CreditsProvider {
17
18
  return {
18
19
  id,
19
20
  label: "Moonshot",
20
21
 
21
22
  async fetch(apiKey, signal): Promise<Credits> {
22
- const headers: Record<string, string> = {
23
- Accept: "application/json",
24
- Authorization: `Bearer ${apiKey}`,
25
- };
26
-
27
- const response = await fetch(`https://${host}/v1/users/me/balance`, { headers, signal });
28
- if (!response.ok) throw new Error("request failed");
29
-
30
- const payload = (await response.json()) as MoonshotBalanceResponse;
23
+ const payload = await withAuth(http, apiKey).get(`https://${host}/v1/users/me/balance`, { signal }).json<MoonshotBalanceResponse>();
31
24
  const remaining = await convertToUSD(toNumber(payload.data?.available_balance), currency, signal);
32
25
 
33
26
  return { type: "balance", remaining };
@@ -4,8 +4,10 @@ import prettyMilliseconds from "pretty-ms";
4
4
 
5
5
  import { confirmCodexReset, formatAvailableResets, showCodexResetLoader, showCodexResetSelector } from "./openai-codex-panel";
6
6
  import { toNumber } from "../../../utils/format";
7
+ import { http, withAuth } from "../../../utils/http";
7
8
 
8
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type { KyInstance } from "ky";
9
11
  import type { CodexResetPanelData } from "./openai-codex-panel";
10
12
  import type { Credits, CreditsLane, CreditsProvider, RefreshCredits } from "../types";
11
13
 
@@ -14,7 +16,6 @@ const BASE_URL = "https://chatgpt.com/backend-api";
14
16
  const USAGE_PATH = "/wham/usage";
15
17
  const RESET_CREDITS_PATH = "/wham/rate-limit-reset-credits";
16
18
  const CONSUME_RESET_PATH = "/wham/rate-limit-reset-credits/consume";
17
- const REQUEST_TIMEOUT_MS = 30_000;
18
19
 
19
20
  interface CodexUsageResponse {
20
21
  rate_limit?: {
@@ -53,11 +54,10 @@ async function runCodexReset(ctx: ExtensionContext, refresh: RefreshCredits): Pr
53
54
  const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
54
55
  if (!apiKey) return;
55
56
 
56
- const headers = buildHeaders(apiKey);
57
+ const client = createClient(apiKey);
57
58
 
58
59
  const credit = await showCodexResetSelector(ctx, async (signal) => {
59
- const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
60
- const [usage, details] = await Promise.all([fetchUsage(headers, requestSignal), fetchResetCredits(headers, requestSignal)]);
60
+ const [usage, details] = await Promise.all([fetchUsage(client, signal), fetchResetCredits(client, signal)]);
61
61
 
62
62
  return {
63
63
  ...details,
@@ -66,63 +66,42 @@ async function runCodexReset(ctx: ExtensionContext, refresh: RefreshCredits): Pr
66
66
  });
67
67
  if (!credit || !(await confirmCodexReset(ctx, credit))) return;
68
68
 
69
- await redeemReset(ctx, headers, credit, refresh);
69
+ await redeemReset(ctx, client, credit, refresh);
70
70
  }
71
71
 
72
- async function redeemReset(ctx: ExtensionContext, headers: Record<string, string>, credit: BankedRateLimitReset, refresh: RefreshCredits): Promise<void> {
72
+ async function redeemReset(ctx: ExtensionContext, client: KyInstance, credit: BankedRateLimitReset, refresh: RefreshCredits): Promise<void> {
73
73
  await showCodexResetLoader(ctx, async () => {
74
- const response = await consumeReset(headers, uuidv7(), credit.id);
74
+ const response = await consumeReset(client, uuidv7(), credit.id);
75
75
  if (response.code === "nothing_to_reset") throw new Error("No Codex rate-limit window currently needs a reset");
76
76
  if (response.code === "no_credit") throw new Error("No Codex resets are available");
77
77
  }).finally(() => refresh(ctx));
78
78
  }
79
79
 
80
- function buildHeaders(apiKey: string): Record<string, string> {
81
- const headers: Record<string, string> = {
82
- Accept: "application/json",
83
- Authorization: `Bearer ${apiKey}`,
84
- };
85
-
80
+ function createClient(apiKey: string): KyInstance {
81
+ const client = withAuth(http, apiKey);
86
82
  const credential = readStoredCredential(PROVIDER);
87
83
  const accountId = credential?.type === "oauth" ? credential.accountId : undefined;
88
- if (typeof accountId === "string" && accountId.length > 0) headers["ChatGPT-Account-ID"] = accountId;
89
-
90
- return headers;
91
- }
92
-
93
- async function requestJson<T>(path: string, headers: Record<string, string>, signal: AbortSignal, init: RequestInit = {}): Promise<T> {
94
- const requestHeaders = new Headers(headers);
95
- new Headers(init.headers).forEach((value, key) => requestHeaders.set(key, value));
96
-
97
- const response = await fetch(`${BASE_URL}${path}`, {
98
- ...init,
99
- headers: requestHeaders,
100
- signal,
101
- });
102
- if (!response.ok) throw new Error(`Codex backend returned ${response.status}`);
103
84
 
104
- return (await response.json()) as T;
85
+ return typeof accountId === "string" && accountId.length > 0
86
+ ? client.extend({ headers: { "ChatGPT-Account-ID": accountId } })
87
+ : client;
105
88
  }
106
89
 
107
- async function fetchUsage(headers: Record<string, string>, signal: AbortSignal): Promise<CodexUsageResponse> {
108
- return requestJson<CodexUsageResponse>(USAGE_PATH, headers, signal);
90
+ async function fetchUsage(client: KyInstance, signal: AbortSignal): Promise<CodexUsageResponse> {
91
+ return client.get(`${BASE_URL}${USAGE_PATH}`, { signal }).json<CodexUsageResponse>();
109
92
  }
110
93
 
111
- async function fetchResetCredits(headers: Record<string, string>, signal: AbortSignal): Promise<RateLimitResetCreditsResponse> {
112
- return requestJson<RateLimitResetCreditsResponse>(RESET_CREDITS_PATH, headers, signal);
94
+ async function fetchResetCredits(client: KyInstance, signal: AbortSignal): Promise<RateLimitResetCreditsResponse> {
95
+ return client.get(`${BASE_URL}${RESET_CREDITS_PATH}`, { signal }).json<RateLimitResetCreditsResponse>();
113
96
  }
114
97
 
115
- async function consumeReset(headers: Record<string, string>, redeemRequestId: string, creditId: string): Promise<ConsumeResetResponse> {
116
- return requestJson<ConsumeResetResponse>(
117
- CONSUME_RESET_PATH,
118
- headers,
119
- AbortSignal.timeout(REQUEST_TIMEOUT_MS),
120
- {
121
- method: "POST",
122
- headers: { "Content-Type": "application/json" },
123
- body: JSON.stringify({ redeem_request_id: redeemRequestId, credit_id: creditId }),
98
+ async function consumeReset(client: KyInstance, redeemRequestId: string, creditId: string): Promise<ConsumeResetResponse> {
99
+ return client.post(`${BASE_URL}${CONSUME_RESET_PATH}`, {
100
+ json: {
101
+ redeem_request_id: redeemRequestId,
102
+ credit_id: creditId
124
103
  },
125
- );
104
+ }).json<ConsumeResetResponse>();
126
105
  }
127
106
 
128
107
  function toCredits(usage: CodexUsageResponse, suffix?: string): Credits {
@@ -153,8 +132,8 @@ export const openaiCodexProvider: CreditsProvider = {
153
132
  label: "Codex",
154
133
 
155
134
  async fetch(apiKey, signal): Promise<Credits> {
156
- const headers = buildHeaders(apiKey);
157
- const usage = await fetchUsage(headers, signal);
135
+ const client = createClient(apiKey);
136
+ const usage = await fetchUsage(client, signal);
158
137
  const availableCount = usage.rate_limit_reset_credits?.available_count ?? 0;
159
138
  const suffix = availableCount > 0 ? `(${formatAvailableResets(availableCount)})` : undefined;
160
139
 
@@ -1,4 +1,5 @@
1
1
  import { toNumber } from "../../../utils/format";
2
+ import { http, withAuth } from "../../../utils/http";
2
3
 
3
4
  import type { Credits, CreditsProvider } from "../types";
4
5
 
@@ -17,15 +18,7 @@ export const openrouterProvider: CreditsProvider = {
17
18
  label: "OpenRouter",
18
19
 
19
20
  async fetch(apiKey, signal): Promise<Credits> {
20
- const headers: Record<string, string> = {
21
- Accept: "application/json",
22
- Authorization: `Bearer ${apiKey}`,
23
- };
24
-
25
- const response = await fetch(URL, { headers, signal });
26
- if (!response.ok) throw new Error("request failed");
27
-
28
- const payload = (await response.json()) as OpenRouterCreditsResponse;
21
+ const payload = await withAuth(http, apiKey).get(URL, { signal }).json<OpenRouterCreditsResponse>();
29
22
  const totalCredits = toNumber(payload.data?.total_credits);
30
23
  const totalUsage = toNumber(payload.data?.total_usage);
31
24
  const remaining = typeof totalCredits === "number" && typeof totalUsage === "number" ? totalCredits - totalUsage : undefined;
@@ -1,4 +1,5 @@
1
1
  import { toNumber } from "../../../utils/format";
2
+ import { http, withAuth } from "../../../utils/http";
2
3
 
3
4
  import type { Credits, CreditsProvider } from "../types";
4
5
 
@@ -14,15 +15,7 @@ export const vercelAiGatewayProvider: CreditsProvider = {
14
15
  label: "Vercel",
15
16
 
16
17
  async fetch(apiKey, signal): Promise<Credits> {
17
- const headers: Record<string, string> = {
18
- Accept: "application/json",
19
- Authorization: `Bearer ${apiKey}`,
20
- };
21
-
22
- const response = await fetch(URL, { headers, signal });
23
- if (!response.ok) throw new Error("request failed");
24
-
25
- const payload = (await response.json()) as VercelCreditsResponse;
18
+ const payload = await withAuth(http, apiKey).get(URL, { signal }).json<VercelCreditsResponse>();
26
19
  const remaining = toNumber(payload.balance);
27
20
 
28
21
  return { type: "balance", remaining };