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.
- package/LICENSE.md +9 -0
- package/README.md +58 -6
- package/assets/screencast-write.mp4 +0 -0
- package/index.ts +2 -0
- package/package.json +14 -9
- package/src/config/schema.ts +2 -0
- package/src/features/credits/config.ts +18 -1
- package/src/features/credits/index.ts +7 -4
- package/src/features/credits/manager.ts +7 -4
- package/src/features/credits/providers/deepseek.ts +2 -9
- package/src/features/credits/providers/index.ts +10 -4
- package/src/features/credits/providers/kimi-code.ts +61 -25
- package/src/features/credits/providers/moonshot.ts +3 -10
- package/src/features/credits/providers/openai-codex.ts +24 -45
- package/src/features/credits/providers/openrouter.ts +2 -9
- package/src/features/credits/providers/vercel-ai-gateway.ts +2 -9
- package/src/features/credits/types.ts +2 -2
- package/src/features/editor/config.ts +6 -1
- package/src/features/editor/index.ts +21 -4
- package/src/features/editor/spinner.ts +2 -7
- package/src/features/footer/config.ts +7 -1
- package/src/features/footer/index.ts +19 -8
- package/src/features/fullscreen/filler.ts +67 -23
- package/src/features/fullscreen/index.ts +12 -8
- package/src/features/presets/config.ts +2 -1
- package/src/features/presets/manager.ts +1 -1
- package/src/features/recap/index.ts +1 -1
- package/src/features/title/index.ts +8 -3
- package/src/features/title/manager.ts +2 -3
- package/src/features/write/config.ts +3 -0
- package/src/features/write/index.ts +177 -0
- package/src/features/write/path.ts +33 -0
- package/src/utils/format.ts +3 -4
- package/src/utils/http.ts +16 -0
- package/src/utils/usage.ts +2 -0
- package/themes/github-dark-default.json +91 -0
- package/themes/github-light-default.json +91 -0
- 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
|
|
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
|
-
-
|
|
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` | `
|
|
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` | `
|
|
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
|
|
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.
|
|
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.
|
|
42
|
-
"@earendil-works/pi-ai": ">=0.
|
|
43
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
44
|
-
"@earendil-works/pi-tui": ">=0.
|
|
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.
|
|
49
|
-
"@earendil-works/pi-ai": ">=0.
|
|
50
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
51
|
-
"@earendil-works/pi-tui": ">=0.
|
|
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"
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
21
|
+
if (ctx.mode !== "tui" || !config) return;
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
|
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
|
|
24
|
-
|
|
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
|
|
28
|
-
|
|
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?:
|
|
10
|
-
limits?:
|
|
11
|
-
|
|
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(
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
38
|
-
|
|
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
|
|
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
|
|
61
|
-
lanes.push(
|
|
92
|
+
const lane = buildLane(formatWindowLabel(item.window), item.detail);
|
|
93
|
+
if (lane) lanes.push(lane);
|
|
62
94
|
});
|
|
63
95
|
|
|
64
|
-
|
|
65
|
-
|
|
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:
|
|
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
|
|
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
|
|
57
|
+
const client = createClient(apiKey);
|
|
57
58
|
|
|
58
59
|
const credit = await showCodexResetSelector(ctx, async (signal) => {
|
|
59
|
-
const
|
|
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,
|
|
69
|
+
await redeemReset(ctx, client, credit, refresh);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
async function redeemReset(ctx: ExtensionContext,
|
|
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(
|
|
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
|
|
81
|
-
const
|
|
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
|
|
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(
|
|
108
|
-
return
|
|
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(
|
|
112
|
-
return
|
|
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(
|
|
116
|
-
return
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
157
|
-
const usage = await fetchUsage(
|
|
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
|
|
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
|
|
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 };
|