pi-spark 0.11.2 → 0.13.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 +76 -57
- package/assets/cover.png +0 -0
- package/index.ts +31 -0
- package/package.json +24 -9
- package/{extensions/shared → src}/components/inline-text.ts +1 -1
- package/{extensions/shared → src}/config/index.ts +37 -12
- package/src/config/schema.ts +30 -0
- package/{extensions/shared → src}/events/auto-collect-events.ts +1 -1
- package/src/events/index.ts +3 -0
- package/src/features/credits/config.ts +3 -0
- package/src/features/credits/index.ts +49 -0
- package/src/features/credits/manager.ts +64 -0
- package/src/features/credits/providers/deepseek.ts +36 -0
- package/src/features/credits/providers/fireworks.proto +42 -0
- package/src/features/credits/providers/fireworks.ts +111 -0
- package/src/features/credits/providers/index.ts +22 -0
- package/src/features/credits/providers/moonshot.ts +39 -0
- package/src/features/credits/providers/openai-codex.ts +62 -0
- package/src/features/credits/providers/openrouter.ts +35 -0
- package/src/features/credits/providers/vercel-ai-gateway.ts +30 -0
- package/src/features/credits/status.ts +43 -0
- package/src/features/credits/types.ts +24 -0
- package/{extensions → src/features}/editor/index.ts +8 -11
- package/{extensions → src/features}/footer/index.ts +7 -9
- package/{extensions → src/features}/fullscreen/index.ts +6 -9
- package/src/features/model/config.ts +3 -0
- package/{extensions/models → src/features/model}/index.ts +5 -5
- package/{extensions → src/features}/name/index.ts +6 -8
- package/{extensions → src/features}/presets/config.ts +1 -1
- package/{extensions → src/features}/presets/index.ts +4 -4
- package/{extensions → src/features}/presets/manager.ts +2 -2
- package/{extensions → src/features}/recap/config.ts +1 -1
- package/{extensions → src/features}/recap/index.ts +4 -6
- package/{extensions → src/features}/recap/manager.ts +5 -5
- package/{extensions → src/features}/recap/model.ts +2 -2
- package/{extensions/shared/format/index.ts → src/utils/format.ts} +29 -0
- package/{extensions/shared/usage/index.ts → src/utils/usage.ts} +0 -18
- package/extensions/models/config.ts +0 -3
- package/extensions/shared/config/schema.ts +0 -22
- package/extensions/shared/events/index.ts +0 -3
- /package/{extensions/shared → src}/components/split-line.ts +0 -0
- /package/{extensions/shared → src}/config/model.ts +0 -0
- /package/{extensions/shared → src}/events/preset.ts +0 -0
- /package/{extensions → src/features}/editor/config.ts +0 -0
- /package/{extensions → src/features}/editor/spinner.ts +0 -0
- /package/{extensions → src/features}/footer/config.ts +0 -0
- /package/{extensions → src/features}/fullscreen/config.ts +0 -0
- /package/{extensions → src/features}/fullscreen/filler.ts +0 -0
- /package/{extensions → src/features}/name/config.ts +0 -0
- /package/{extensions → src/features}/presets/selector.ts +0 -0
- /package/{extensions → src/features}/recap/idle.ts +0 -0
- /package/{extensions → src/features}/recap/widget.ts +0 -0
package/README.md
CHANGED
|
@@ -1,40 +1,54 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
# pi-spark
|
|
2
4
|
|
|
3
|
-
[Pi](https://pi.dev/)
|
|
5
|
+
[Pi](https://pi.dev/) package that polishes your daily experience and keeps you at the frontier of agentic workflows.
|
|
4
6
|
|
|
5
|
-
##
|
|
7
|
+
## Features
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
- **Footer:** shows session information, extension statuses, cost, and context usage on one line.
|
|
9
|
-
- **Fullscreen:** clears the screen and scrollback on session start, pins the editor and footer to the bottom for a full-screen session, and clears again on exit.
|
|
10
|
-
- **Models:** exposes a `model` tool so the agent can list the models the user can use currently and inspect the provider, model, and thinking level in use.
|
|
11
|
-
- **Name:** exposes a `name` tool so the agent can give the current session a concise, recognizable name in the session selector.
|
|
12
|
-
- **Presets:** switches named model presets with `/preset`, `--preset`, and quick cycle shortcuts.
|
|
13
|
-
- **Recap:** generates a short idle-session recap and exposes a `/recap` command for manual generation, inspired by [Claude Code's session recap](https://code.claude.com/docs/en/interactive-mode#session-recap).
|
|
9
|
+
### Compact TUI: editor, footer, and fullscreen
|
|
14
10
|
|
|
15
|
-
|
|
11
|
+
pi-spark ships with custom editor, footer, and fullscreen rendering, replacing the default ones. The compact TUI gives you a calm, immersive experience without distraction.
|
|
16
12
|
|
|
17
|
-
|
|
13
|
+
- The editor shows a working indicator inspired by [Amp](https://ampcode.com/) and the current model on the top border. If you use [presets](#presets), the active preset appears there too.
|
|
14
|
+
- The footer shows session information, extension statuses, cost, and context usage on one line.
|
|
15
|
+
- The fullscreen rendering clears the terminal screen and scrollback on session start and exit, and pins the editor and footer to the bottom.
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
### Credits
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
pi install npm:pi-spark
|
|
23
|
-
```
|
|
19
|
+
pi-spark shows the active provider's credit balance or rate-limit usage in the status line, so you can keep an eye on what's left without leaving the terminal.
|
|
24
20
|
|
|
25
|
-
|
|
21
|
+
- Supported providers: DeepSeek, Fireworks, Moonshot, OpenAI Codex, OpenRouter, and Vercel AI Gateway.
|
|
22
|
+
- Most provider fetching follows [CodexBar](https://github.com/steipete/codexbar). Fireworks is the exception: its balance sits behind an internal gRPC API, reverse-engineered from the `firectl` binary (see [docs/fireworks.md](./docs/fireworks.md)).
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
### Presets
|
|
25
|
+
|
|
26
|
+
pi-spark lets you define named model presets in `spark.json` (see [Configuration](#configuration)), so you can switch between models and thinking levels without retyping provider details. The active preset is shown on the editor's top border.
|
|
27
|
+
|
|
28
|
+
- Switch interactively with `/preset`, or jump straight to one with `/preset <name>`.
|
|
29
|
+
- Start pi on a given preset with `pi --preset <name>`.
|
|
30
|
+
- Cycle presets with `ctrl+super+p` (forward) and `ctrl+shift+super+p` (backward); `super` is `command` on macOS and needs a terminal that forwards it.
|
|
31
|
+
|
|
32
|
+
### Recap
|
|
33
|
+
|
|
34
|
+
pi-spark generates a short recap of the current session after it goes idle, or on demand, inspired by [Claude Code's session recap](https://code.claude.com/docs/en/interactive-mode#session-recap).
|
|
35
|
+
|
|
36
|
+
- A recap is generated automatically once the session stays idle past `recap.idle` in `spark.json`.
|
|
37
|
+
- Run `/recap` to generate one manually at any time.
|
|
38
|
+
- The recap can use its own model, configured separately from your working model.
|
|
30
39
|
|
|
31
|
-
|
|
40
|
+
### Agent tools: `name` and `model`
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
pi-spark provides the pi coding agent with tools to inspect and manipulate itself. Currently, `name` and `model` tools are included.
|
|
34
43
|
|
|
35
|
-
|
|
44
|
+
- `name` sets or updates the current session's name.
|
|
45
|
+
- `model` shows the active model or lists pi models.
|
|
36
46
|
|
|
37
|
-
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
pi-spark reads config from `~/.pi/agent/spark.json` and from the current project's `.pi/spark.json`. Project config overrides matching global fields.
|
|
50
|
+
|
|
51
|
+
For example:
|
|
38
52
|
|
|
39
53
|
```json
|
|
40
54
|
{
|
|
@@ -63,56 +77,61 @@ Example:
|
|
|
63
77
|
}
|
|
64
78
|
```
|
|
65
79
|
|
|
66
|
-
###
|
|
67
|
-
|
|
68
|
-
- `editor.spinner` controls the working indicator style and can be `dots`, `lights`, `tildes`, or `pulse`.
|
|
69
|
-
|
|
70
|
-
### Footer
|
|
71
|
-
|
|
72
|
-
- pi-spark replaces the footer with a compact one-line view of session metadata, extension statuses, cost, and context usage.
|
|
80
|
+
### References
|
|
73
81
|
|
|
74
|
-
|
|
82
|
+
All fields are optional. Each top-level feature runs with the defaults below unless you [turn it off](#turn-off-the-features-you-dont-like).
|
|
75
83
|
|
|
76
|
-
|
|
84
|
+
| Field | Value (or `false`) | Description |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| `credits` | `{}` | Shows the active provider's credit balance or rate-limit usage in the status line. |
|
|
87
|
+
| `editor` | `EditorConfig` | Shows a working indicator and the current model on the editor's top border. |
|
|
88
|
+
| `footer` | `{}` | Shows session info, extension statuses, cost, and context usage on one line. |
|
|
89
|
+
| `fullscreen` | `{}` | Clears the screen and scrollback on start and exit, and pins the editor and footer to the bottom. |
|
|
90
|
+
| `model` | `{}` | Exposes the `model` agent tool. |
|
|
91
|
+
| `name` | `{}` | Exposes the `name` agent tool. |
|
|
92
|
+
| `presets` | `{ [name]: Preset }` | Defines named model presets, keyed by name. |
|
|
93
|
+
| `recap` | `RecapConfig` | Generates a session recap when idle or on demand. |
|
|
77
94
|
|
|
78
|
-
|
|
95
|
+
#### `EditorConfig`
|
|
79
96
|
|
|
80
|
-
|
|
81
|
-
- `active`: gets the active provider, model, and thinking level.
|
|
82
|
-
- `list`: lists models with their metadata, with an optional [Liqe](https://github.com/gajus/liqe) (Lucene-like) `query` over model fields and `offset`/`limit` paging.
|
|
97
|
+
The `spinner` field is optional and defaults to `tildes`.
|
|
83
98
|
|
|
84
|
-
|
|
99
|
+
| Field | Value | Description |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `spinner` | `dots` | `⠋, ⠙, ⠹, ⠸, ⠼, ⠴, ⠦, ⠧, ⠇, ⠏` |
|
|
102
|
+
| | `lights` | `○, ●` |
|
|
103
|
+
| | `tildes` (default) | `∼, ≈, ≋, ≈, ∼` |
|
|
104
|
+
| | `pulse` | `·, •, ●, •, ·` |
|
|
85
105
|
|
|
86
|
-
|
|
106
|
+
#### `Preset`
|
|
87
107
|
|
|
88
|
-
|
|
108
|
+
Each preset must set all three fields.
|
|
89
109
|
|
|
90
|
-
|
|
110
|
+
| Field | Value | Description |
|
|
111
|
+
| --- | --- | --- |
|
|
112
|
+
| `provider` | string | Provider id, e.g. `anthropic`. |
|
|
113
|
+
| `model` | string | Model id, e.g. `claude-opus-4-8`. |
|
|
114
|
+
| `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the preset. |
|
|
91
115
|
|
|
92
|
-
|
|
116
|
+
#### `RecapConfig`
|
|
93
117
|
|
|
94
|
-
|
|
95
|
-
- Start pi with a preset using `pi --preset <key>`
|
|
96
|
-
- Cycle presets with `ctrl+super+p` and `ctrl+shift+super+p` (`super` is `command` on macOS)
|
|
118
|
+
All fields are optional, including `thinkingLevel`.
|
|
97
119
|
|
|
98
|
-
|
|
120
|
+
| Field | Value | Description |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `idle` | number (ms) or duration string | How long the session must stay idle before a recap is generated. Accepts a millisecond number or a [vercel/ms](https://github.com/vercel/ms) string (e.g. `"3m"`); minimum 5000 ms, defaults to 3 minutes. |
|
|
123
|
+
| `provider` | string | Provider id for the recap model. |
|
|
124
|
+
| `model` | string | Model id for the recap model. |
|
|
125
|
+
| `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the recap model. |
|
|
99
126
|
|
|
100
|
-
|
|
101
|
-
- The `recap.idle` value sets how long the session must stay idle before a recap is generated. It accepts either a millisecond number or a human-readable duration string parsed by [vercel/ms](https://github.com/vercel/ms) (e.g., `"3m"`, `"30s"`, `"2 minutes"`), and must resolve to at least 5000ms.
|
|
102
|
-
- The recap model can be customized with `provider`, `model`, and `thinkingLevel`.
|
|
127
|
+
### Turn off the features you don't like
|
|
103
128
|
|
|
104
|
-
|
|
129
|
+
All features are enabled by default. Set a specific feature to `false` in `spark.json` to disable it.
|
|
105
130
|
|
|
106
|
-
|
|
131
|
+
For example, to disable the customized footer:
|
|
107
132
|
|
|
108
133
|
```json
|
|
109
134
|
{
|
|
110
|
-
"
|
|
135
|
+
"footer": false
|
|
111
136
|
}
|
|
112
137
|
```
|
|
113
|
-
|
|
114
|
-
Project trust is an [input-loading guard](https://pi.dev/docs/latest/security#project-trust), so use `"always"` only if you trust the projects you open.
|
|
115
|
-
|
|
116
|
-
## Other pi packages
|
|
117
|
-
|
|
118
|
-
See my [pi-packages](https://github.com/zlliang/pi-packages) repo for the full list.
|
package/assets/cover.png
ADDED
|
Binary file
|
package/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { autoCollectEvents } from "./src/events";
|
|
2
|
+
import { registerCredits } from "./src/features/credits";
|
|
3
|
+
import { registerEditor } from "./src/features/editor";
|
|
4
|
+
import { registerFooter } from "./src/features/footer";
|
|
5
|
+
import { registerFullscreen } from "./src/features/fullscreen";
|
|
6
|
+
import { registerModel } from "./src/features/model";
|
|
7
|
+
import { registerName } from "./src/features/name";
|
|
8
|
+
import { registerPresets } from "./src/features/presets";
|
|
9
|
+
import { registerRecap } from "./src/features/recap";
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* pi-spark is a single extension that bundles several features. Each feature registers itself
|
|
15
|
+
* like a standalone extension and reads its config via the shared, cached `loadConfig`. This file
|
|
16
|
+
* is just the registry that wires them together.
|
|
17
|
+
*/
|
|
18
|
+
export default function (pi: ExtensionAPI) {
|
|
19
|
+
// Own the event-bus subscription lifecycle here so features never manage cleanup themselves;
|
|
20
|
+
// the collector disposes every subscription on session_shutdown.
|
|
21
|
+
const events = autoCollectEvents(pi);
|
|
22
|
+
|
|
23
|
+
registerCredits(pi);
|
|
24
|
+
registerEditor(pi, events);
|
|
25
|
+
registerFooter(pi);
|
|
26
|
+
registerFullscreen(pi);
|
|
27
|
+
registerModel(pi);
|
|
28
|
+
registerName(pi);
|
|
29
|
+
registerPresets(pi);
|
|
30
|
+
registerRecap(pi);
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,30 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-spark",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "Pi package that polishes your daily experience and keeps you at the frontier of agentic workflows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-coding-agent",
|
|
7
7
|
"pi-package"
|
|
8
8
|
],
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/zlliang/pi-
|
|
12
|
-
"directory": "packages/pi-spark"
|
|
11
|
+
"url": "git+https://github.com/zlliang/pi-spark.git"
|
|
13
12
|
},
|
|
14
13
|
"license": "MIT",
|
|
15
14
|
"type": "module",
|
|
16
15
|
"files": [
|
|
17
|
-
"
|
|
16
|
+
"index.ts",
|
|
17
|
+
"src",
|
|
18
18
|
"assets",
|
|
19
|
-
"README.md"
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
20
21
|
],
|
|
21
22
|
"pi": {
|
|
22
23
|
"extensions": [
|
|
23
|
-
"./
|
|
24
|
+
"./index.ts"
|
|
24
25
|
],
|
|
25
|
-
"image": "https://raw.githubusercontent.com/zlliang/pi-
|
|
26
|
+
"image": "https://raw.githubusercontent.com/zlliang/pi-spark/main/assets/cover.png"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit"
|
|
26
30
|
},
|
|
27
31
|
"dependencies": {
|
|
32
|
+
"@grpc/grpc-js": "^1.14.4",
|
|
33
|
+
"@grpc/proto-loader": "^0.8.1",
|
|
28
34
|
"liqe": "^3.8.7",
|
|
29
35
|
"ms": "4.0.0-nightly.202508271359",
|
|
30
36
|
"zod": "^4.4.3"
|
|
@@ -35,5 +41,14 @@
|
|
|
35
41
|
"@earendil-works/pi-coding-agent": "*",
|
|
36
42
|
"@earendil-works/pi-tui": "*",
|
|
37
43
|
"typebox": "*"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@earendil-works/pi-agent-core": "*",
|
|
47
|
+
"@earendil-works/pi-ai": "*",
|
|
48
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
49
|
+
"@earendil-works/pi-tui": "*",
|
|
50
|
+
"@types/node": "*",
|
|
51
|
+
"typebox": "*",
|
|
52
|
+
"typescript": "^6.0.3"
|
|
38
53
|
}
|
|
39
|
-
}
|
|
54
|
+
}
|
|
@@ -2,26 +2,51 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { sparkConfigSchema } from "./schema";
|
|
6
6
|
|
|
7
7
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import type {
|
|
8
|
+
import type { SparkConfig } from "./schema";
|
|
9
9
|
|
|
10
10
|
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
11
11
|
type JsonObject = { [key: string]: JsonValue };
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
/** All features disabled; used as the fallback when `spark.json` fails validation. */
|
|
14
|
+
const DISABLED_CONFIG: SparkConfig = Object.freeze({
|
|
15
|
+
credits: false,
|
|
16
|
+
editor: false,
|
|
17
|
+
footer: false,
|
|
18
|
+
fullscreen: false,
|
|
19
|
+
model: false,
|
|
20
|
+
name: false,
|
|
21
|
+
presets: false,
|
|
22
|
+
recap: false,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const cache = new Map<string, SparkConfig>();
|
|
26
|
+
|
|
27
|
+
/** Load and validate spark.json once per session lifecycle; later calls return the cached result. */
|
|
28
|
+
export function loadConfig(ctx: ExtensionContext, fileName: string = "spark.json"): SparkConfig {
|
|
29
|
+
const key = `${ctx.cwd}\u0000${fileName}`;
|
|
30
|
+
const cached = cache.get(key);
|
|
31
|
+
if (cached) return cached;
|
|
32
|
+
|
|
33
|
+
const rawConfig = loadMergedJson(getConfigPaths(ctx.cwd, fileName)) ?? {};
|
|
34
|
+
const result = sparkConfigSchema.safeParse(rawConfig);
|
|
35
|
+
|
|
36
|
+
if (!result.success) {
|
|
37
|
+
const message = result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
|
38
|
+
ctx.ui.notify(`Invalid spark config: ${message}`, "error");
|
|
39
|
+
|
|
40
|
+
const config = DISABLED_CONFIG;
|
|
41
|
+
cache.set(key, config);
|
|
42
|
+
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
20
45
|
|
|
21
|
-
const
|
|
22
|
-
|
|
46
|
+
const config = result.data;
|
|
47
|
+
cache.set(key, config);
|
|
23
48
|
|
|
24
|
-
return
|
|
49
|
+
return config;
|
|
25
50
|
}
|
|
26
51
|
|
|
27
52
|
function getConfigPaths(cwd: string, fileName: string): [globalPath: string, projectPath: string] {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
|
|
3
|
+
import { creditsConfigSchema } from "../features/credits/config";
|
|
4
|
+
import { editorConfigSchema } from "../features/editor/config";
|
|
5
|
+
import { footerConfigSchema } from "../features/footer/config";
|
|
6
|
+
import { fullscreenConfigSchema } from "../features/fullscreen/config";
|
|
7
|
+
import { modelConfigSchema } from "../features/model/config";
|
|
8
|
+
import { nameConfigSchema } from "../features/name/config";
|
|
9
|
+
import { presetsConfigSchema } from "../features/presets/config";
|
|
10
|
+
import { recapConfigSchema } from "../features/recap/config";
|
|
11
|
+
|
|
12
|
+
const disabled = z.literal(false);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Each feature field is `{ ... } | false`: `false` disables the feature, an object configures
|
|
16
|
+
* it, and an omitted field falls back to defaults (`{}`, enabled).
|
|
17
|
+
*/
|
|
18
|
+
export const sparkConfigSchema = z.object({
|
|
19
|
+
credits: creditsConfigSchema.or(disabled).default({}),
|
|
20
|
+
editor: editorConfigSchema.or(disabled).default({}),
|
|
21
|
+
footer: footerConfigSchema.or(disabled).default({}),
|
|
22
|
+
fullscreen: fullscreenConfigSchema.or(disabled).default({}),
|
|
23
|
+
model: modelConfigSchema.or(disabled).default({}),
|
|
24
|
+
name: nameConfigSchema.or(disabled).default({}),
|
|
25
|
+
presets: presetsConfigSchema.or(disabled).default({}),
|
|
26
|
+
recap: recapConfigSchema.or(disabled).default({}),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** Resolved config for every feature; `false` means the feature is disabled. */
|
|
30
|
+
export type SparkConfig = z.infer<typeof sparkConfigSchema>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CreditsManager } from "./manager";
|
|
2
|
+
import { loadConfig } from "../../config";
|
|
3
|
+
import { isUsage } from "../../utils/usage";
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
7
|
+
|
|
8
|
+
function hasCost(message: AgentMessage): boolean {
|
|
9
|
+
const usage = (message as { usage?: unknown }).usage;
|
|
10
|
+
if (!isUsage(usage)) return false;
|
|
11
|
+
|
|
12
|
+
return usage.cost.total > 0 || usage.input > 0 || usage.output > 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerCredits(pi: ExtensionAPI): void {
|
|
16
|
+
let creditsManager: CreditsManager | undefined = undefined;
|
|
17
|
+
|
|
18
|
+
pi.on("session_start", (_event, ctx) => {
|
|
19
|
+
const config = loadConfig(ctx).credits;
|
|
20
|
+
if (!ctx.hasUI || !config) return;
|
|
21
|
+
|
|
22
|
+
creditsManager = new CreditsManager();
|
|
23
|
+
creditsManager.refresh(ctx);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
pi.on("model_select", (_event, ctx) => {
|
|
27
|
+
creditsManager?.refresh(ctx);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
pi.on("turn_end", (event, ctx) => {
|
|
31
|
+
if (!hasCost(event.message)) return;
|
|
32
|
+
|
|
33
|
+
creditsManager?.refresh(ctx);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
pi.on("session_compact", (_event, ctx) => {
|
|
37
|
+
creditsManager?.refresh(ctx);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
pi.on("session_tree", (event, ctx) => {
|
|
41
|
+
if (!event.summaryEntry) return;
|
|
42
|
+
|
|
43
|
+
creditsManager?.refresh(ctx);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pi.on("session_shutdown", () => {
|
|
47
|
+
creditsManager = undefined;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { findProvider } from "./providers";
|
|
2
|
+
import { renderCredits, renderError } from "./status";
|
|
3
|
+
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { CreditsProvider } from "./types";
|
|
6
|
+
|
|
7
|
+
const STATUS_KEY = "credits";
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
export class CreditsManager {
|
|
11
|
+
private inflight: AbortController | undefined = undefined;
|
|
12
|
+
private currentProvider: string | undefined = undefined;
|
|
13
|
+
|
|
14
|
+
async refresh(ctx: ExtensionContext): Promise<void> {
|
|
15
|
+
this.inflight?.abort();
|
|
16
|
+
|
|
17
|
+
const provider = findProvider(ctx.model?.provider);
|
|
18
|
+
if (!provider) {
|
|
19
|
+
this.inflight = undefined;
|
|
20
|
+
this.currentProvider = undefined;
|
|
21
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Clear stale credits from another provider while the new fetch is in flight.
|
|
26
|
+
if (this.currentProvider !== provider.id) {
|
|
27
|
+
this.currentProvider = provider.id;
|
|
28
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
this.inflight = controller;
|
|
33
|
+
|
|
34
|
+
await this.fetch(ctx, provider, controller.signal).finally(() => {
|
|
35
|
+
if (this.inflight === controller) this.inflight = undefined;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async fetch(ctx: ExtensionContext, provider: CreditsProvider, signal: AbortSignal): Promise<void> {
|
|
40
|
+
try {
|
|
41
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider.id);
|
|
42
|
+
if (!apiKey) {
|
|
43
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal];
|
|
48
|
+
if (ctx.signal) signals.push(ctx.signal);
|
|
49
|
+
|
|
50
|
+
const credits = await provider.fetch(ctx, apiKey, AbortSignal.any(signals));
|
|
51
|
+
|
|
52
|
+
// The active model may have changed while the request was in flight.
|
|
53
|
+
if (ctx.model?.provider !== provider.id) return;
|
|
54
|
+
|
|
55
|
+
ctx.ui.setStatus(STATUS_KEY, renderCredits(ctx.ui.theme, provider.label, credits));
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (signal.aborted || ctx.signal?.aborted) return;
|
|
58
|
+
if (ctx.model?.provider !== provider.id) return;
|
|
59
|
+
|
|
60
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
61
|
+
ctx.ui.setStatus(STATUS_KEY, renderError(ctx.ui.theme, provider.label, message));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { convertToUSD, toNumber } from "../../../utils/format";
|
|
2
|
+
|
|
3
|
+
import type { Credits, CreditsProvider } from "../types";
|
|
4
|
+
|
|
5
|
+
const PROVIDER = "deepseek";
|
|
6
|
+
const URL = "https://api.deepseek.com/user/balance";
|
|
7
|
+
|
|
8
|
+
interface DeepSeekBalanceResponse {
|
|
9
|
+
balance_infos?: DeepSeekBalanceInfo[] | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface DeepSeekBalanceInfo {
|
|
13
|
+
currency?: string;
|
|
14
|
+
total_balance?: string | number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const deepseekProvider: CreditsProvider = {
|
|
18
|
+
id: PROVIDER,
|
|
19
|
+
label: "DeepSeek",
|
|
20
|
+
|
|
21
|
+
async fetch(_ctx, 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;
|
|
31
|
+
const balance = payload.balance_infos?.find((entry) => entry.currency === "USD") ?? payload.balance_infos?.[0];
|
|
32
|
+
const remaining = await convertToUSD(toNumber(balance?.total_balance), balance?.currency, signal);
|
|
33
|
+
|
|
34
|
+
return { type: "balance", remaining };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
syntax = "proto3";
|
|
2
|
+
|
|
3
|
+
// Minimal slice of the Fireworks control-plane gateway API (`gateway.Gateway`), reconstructed from
|
|
4
|
+
// the `firectl` binary. Only the messages and RPCs needed to read an account's credit balance are
|
|
5
|
+
// declared here.
|
|
6
|
+
package gateway;
|
|
7
|
+
|
|
8
|
+
// Mirrors google.type.Money.
|
|
9
|
+
message Money {
|
|
10
|
+
string currency_code = 1;
|
|
11
|
+
int64 units = 2;
|
|
12
|
+
int32 nanos = 3;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
message Balance {
|
|
16
|
+
Money money = 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
message GetBalanceRequest {
|
|
20
|
+
string name = 1; // resource name, e.g., "accounts/<account_id>"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
message Account {
|
|
24
|
+
string name = 1; // resource name, e.g., "accounts/<account_id>"
|
|
25
|
+
string display_name = 2;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
message ListAccountsRequest {
|
|
29
|
+
int32 page_size = 1;
|
|
30
|
+
string page_token = 2;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
message ListAccountsResponse {
|
|
34
|
+
repeated Account accounts = 1;
|
|
35
|
+
string next_page_token = 2;
|
|
36
|
+
int32 total_size = 3;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
service Gateway {
|
|
40
|
+
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
|
|
41
|
+
rpc GetBalance(GetBalanceRequest) returns (Balance);
|
|
42
|
+
}
|