@aliou/pi-synthetic 0.8.5 → 0.10.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 +16 -7
- package/package.json +2 -2
- package/src/extensions/command-quotas/command.ts +16 -4
- package/src/extensions/command-quotas/components/quotas-display.ts +181 -23
- package/src/extensions/command-quotas/index.ts +0 -5
- package/src/extensions/command-quotas/sub-integration.ts +80 -26
- package/src/extensions/provider/models.test.ts +1 -1
- package/src/extensions/provider/models.ts +1 -1
- package/src/extensions/web-search/index.ts +0 -7
- package/src/extensions/web-search/tool.ts +6 -3
- package/src/lib/env.ts +16 -5
- package/src/types/quotas.ts +18 -4
- package/src/utils/quotas.ts +7 -6
- package/src/extensions/web-search/hooks.ts +0 -104
package/README.md
CHANGED
|
@@ -8,18 +8,27 @@ A Pi extension that adds [Synthetic](https://synthetic.new) as a model provider,
|
|
|
8
8
|
|
|
9
9
|
Sign up at [synthetic.new](https://synthetic.new/?referral=NDWw1u3UDWiFyDR) to get an API key (referral link).
|
|
10
10
|
|
|
11
|
-
###
|
|
11
|
+
### Configure Credentials
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
The extension uses Pi's credential storage. Add your API key to `~/.pi/agent/auth.json` (recommended):
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"synthetic": { "type": "api_key", "key": "your-api-key-here" }
|
|
18
|
+
}
|
|
15
19
|
```
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
Or set environment variable:
|
|
18
22
|
|
|
19
23
|
```bash
|
|
20
|
-
|
|
24
|
+
export SYNTHETIC_API_KEY="your-api-key-here"
|
|
21
25
|
```
|
|
22
26
|
|
|
27
|
+
Credentials are resolved in this order:
|
|
28
|
+
1. CLI `--api-key` flag
|
|
29
|
+
2. `auth.json` entry for `synthetic`
|
|
30
|
+
3. Environment variable `SYNTHETIC_API_KEY`
|
|
31
|
+
|
|
23
32
|
### Install Extension
|
|
24
33
|
|
|
25
34
|
```bash
|
|
@@ -43,7 +52,7 @@ Once installed, select `synthetic` as your provider and choose from available mo
|
|
|
43
52
|
|
|
44
53
|
### Web Search Tool
|
|
45
54
|
|
|
46
|
-
The extension registers `synthetic_web_search` — a zero-data-retention web search tool.
|
|
55
|
+
The extension registers `synthetic_web_search` — a zero-data-retention web search tool. The tool is always visible; it fails with a clear message if credentials are missing or the account lacks a subscription.
|
|
47
56
|
|
|
48
57
|
### Reasoning Levels
|
|
49
58
|
|
|
@@ -124,7 +133,7 @@ This repository uses [Changesets](https://github.com/changesets/changesets) for
|
|
|
124
133
|
## Requirements
|
|
125
134
|
|
|
126
135
|
- Pi coding agent v0.50.0+
|
|
127
|
-
-
|
|
136
|
+
- Synthetic API key (configured in `~/.pi/agent/auth.json` or via `SYNTHETIC_API_KEY`)
|
|
128
137
|
|
|
129
138
|
## Links
|
|
130
139
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-synthetic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@aliou/pi-utils-ui": "^0.1.2"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@aliou/biome-plugins": "^0.
|
|
39
|
+
"@aliou/biome-plugins": "^0.7.0",
|
|
40
40
|
"@biomejs/biome": "^2.4.2",
|
|
41
41
|
"@changesets/cli": "^2.27.11",
|
|
42
42
|
"@mariozechner/pi-coding-agent": "0.61.0",
|
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { getSyntheticApiKey } from "../../lib/env";
|
|
2
3
|
import { fetchQuotas } from "../../utils/quotas";
|
|
3
4
|
import { QuotasComponent } from "./components/quotas-display";
|
|
4
5
|
|
|
6
|
+
const MISSING_AUTH_MESSAGE =
|
|
7
|
+
"Synthetic quotas requires a Synthetic subscription. Add credentials to ~/.pi/agent/auth.json or set SYNTHETIC_API_KEY environment variable.";
|
|
8
|
+
|
|
5
9
|
export function registerQuotasCommand(pi: ExtensionAPI): void {
|
|
6
10
|
pi.registerCommand("synthetic:quotas", {
|
|
7
11
|
description: "Display Synthetic API usage quotas",
|
|
8
12
|
handler: async (_args, ctx) => {
|
|
13
|
+
const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
ctx.ui.notify(MISSING_AUTH_MESSAGE, "warning");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
const result = await ctx.ui.custom<null>((tui, theme, _kb, done) => {
|
|
10
20
|
const component = new QuotasComponent(theme, () => done(null));
|
|
11
21
|
|
|
12
|
-
fetchQuotas()
|
|
22
|
+
fetchQuotas(apiKey)
|
|
13
23
|
.then((quotas) => {
|
|
14
24
|
if (!quotas) {
|
|
15
25
|
component.setState({
|
|
16
26
|
type: "error",
|
|
17
|
-
message:
|
|
27
|
+
message:
|
|
28
|
+
"Failed to fetch quotas. Check your Synthetic subscription status.",
|
|
18
29
|
});
|
|
19
30
|
} else {
|
|
20
31
|
component.setState({ type: "loaded", quotas });
|
|
@@ -24,7 +35,8 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
|
|
|
24
35
|
.catch(() => {
|
|
25
36
|
component.setState({
|
|
26
37
|
type: "error",
|
|
27
|
-
message:
|
|
38
|
+
message:
|
|
39
|
+
"Failed to fetch quotas. Check your Synthetic subscription status.",
|
|
28
40
|
});
|
|
29
41
|
tui.requestRender();
|
|
30
42
|
});
|
|
@@ -38,7 +50,7 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
|
|
|
38
50
|
|
|
39
51
|
// RPC fallback: return JSON
|
|
40
52
|
if (result === undefined) {
|
|
41
|
-
const quotas = await fetchQuotas();
|
|
53
|
+
const quotas = await fetchQuotas(apiKey);
|
|
42
54
|
if (!quotas) {
|
|
43
55
|
ctx.ui.notify(
|
|
44
56
|
JSON.stringify({ error: "Failed to fetch quotas" }),
|
|
@@ -20,16 +20,77 @@ interface QuotaWindow {
|
|
|
20
20
|
windowSeconds: number;
|
|
21
21
|
usedValue: number;
|
|
22
22
|
limitValue: number;
|
|
23
|
+
isCredits?: boolean;
|
|
24
|
+
isLimited?: boolean;
|
|
25
|
+
tickPercent?: number;
|
|
26
|
+
nextRegenCredits?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Safely compute percentage, guarding against division by zero */
|
|
30
|
+
function safePercent(used: number, limit: number): number {
|
|
31
|
+
if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0) return 0;
|
|
32
|
+
return Math.max(0, Math.min(100, (used / limit) * 100));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Parse currency string like "$1,234.56" to number */
|
|
36
|
+
function parseCurrency(value: string): number {
|
|
37
|
+
const n = Number(value.replace(/[^0-9.-]/g, ""));
|
|
38
|
+
return Number.isFinite(n) ? n : 0;
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
function toWindows(quotas: QuotasResponse): QuotaWindow[] {
|
|
26
42
|
const windows: QuotaWindow[] = [];
|
|
27
43
|
|
|
28
|
-
|
|
44
|
+
// Weekly token limit (credits-based)
|
|
45
|
+
if (quotas.weeklyTokenLimit) {
|
|
46
|
+
const { weeklyTokenLimit } = quotas;
|
|
47
|
+
const limitValue = parseCurrency(weeklyTokenLimit.maxCredits);
|
|
48
|
+
const remainingValue = parseCurrency(weeklyTokenLimit.remainingCredits);
|
|
49
|
+
windows.push({
|
|
50
|
+
label: "Credits",
|
|
51
|
+
usedPercent: Math.max(
|
|
52
|
+
0,
|
|
53
|
+
Math.min(100, 100 - weeklyTokenLimit.percentRemaining),
|
|
54
|
+
),
|
|
55
|
+
resetsAt: new Date(weeklyTokenLimit.nextRegenAt),
|
|
56
|
+
windowSeconds: 7 * 24 * 60 * 60,
|
|
57
|
+
usedValue: limitValue - remainingValue,
|
|
58
|
+
limitValue,
|
|
59
|
+
isCredits: true,
|
|
60
|
+
nextRegenCredits: weeklyTokenLimit.nextRegenCredits,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Rolling 5-hour limit (request-based)
|
|
65
|
+
if (quotas.rollingFiveHourLimit && quotas.rollingFiveHourLimit.max > 0) {
|
|
66
|
+
const { rollingFiveHourLimit } = quotas;
|
|
67
|
+
windows.push({
|
|
68
|
+
label: "5h",
|
|
69
|
+
usedPercent: safePercent(
|
|
70
|
+
rollingFiveHourLimit.max - rollingFiveHourLimit.remaining,
|
|
71
|
+
rollingFiveHourLimit.max,
|
|
72
|
+
),
|
|
73
|
+
resetsAt: new Date(rollingFiveHourLimit.nextTickAt),
|
|
74
|
+
windowSeconds: 5 * 60 * 60,
|
|
75
|
+
usedValue: rollingFiveHourLimit.max - rollingFiveHourLimit.remaining,
|
|
76
|
+
limitValue: rollingFiveHourLimit.max,
|
|
77
|
+
isLimited: rollingFiveHourLimit.limited,
|
|
78
|
+
tickPercent: rollingFiveHourLimit.tickPercent,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Legacy subscription (fallback if rollingFiveHourLimit not available)
|
|
83
|
+
if (
|
|
84
|
+
!quotas.rollingFiveHourLimit &&
|
|
85
|
+
quotas.subscription?.limit &&
|
|
86
|
+
quotas.subscription.limit > 0
|
|
87
|
+
) {
|
|
29
88
|
windows.push({
|
|
30
89
|
label: "Completions",
|
|
31
|
-
usedPercent:
|
|
32
|
-
|
|
90
|
+
usedPercent: safePercent(
|
|
91
|
+
quotas.subscription.requests,
|
|
92
|
+
quotas.subscription.limit,
|
|
93
|
+
),
|
|
33
94
|
resetsAt: new Date(quotas.subscription.renewsAt),
|
|
34
95
|
windowSeconds: 5 * 60 * 60,
|
|
35
96
|
usedValue: quotas.subscription.requests,
|
|
@@ -37,11 +98,13 @@ function toWindows(quotas: QuotasResponse): QuotaWindow[] {
|
|
|
37
98
|
});
|
|
38
99
|
}
|
|
39
100
|
|
|
40
|
-
if (quotas.search.hourly.limit > 0) {
|
|
101
|
+
if (quotas.search?.hourly?.limit && quotas.search.hourly.limit > 0) {
|
|
41
102
|
windows.push({
|
|
42
103
|
label: "Search",
|
|
43
|
-
usedPercent:
|
|
44
|
-
|
|
104
|
+
usedPercent: safePercent(
|
|
105
|
+
quotas.search.hourly.requests,
|
|
106
|
+
quotas.search.hourly.limit,
|
|
107
|
+
),
|
|
45
108
|
resetsAt: new Date(quotas.search.hourly.renewsAt),
|
|
46
109
|
windowSeconds: 60 * 60,
|
|
47
110
|
usedValue: quotas.search.hourly.requests,
|
|
@@ -49,11 +112,13 @@ function toWindows(quotas: QuotasResponse): QuotaWindow[] {
|
|
|
49
112
|
});
|
|
50
113
|
}
|
|
51
114
|
|
|
52
|
-
if (quotas.freeToolCalls.limit > 0) {
|
|
115
|
+
if (quotas.freeToolCalls?.limit && quotas.freeToolCalls.limit > 0) {
|
|
53
116
|
windows.push({
|
|
54
117
|
label: "Free Tool Calls",
|
|
55
|
-
usedPercent:
|
|
56
|
-
|
|
118
|
+
usedPercent: safePercent(
|
|
119
|
+
quotas.freeToolCalls.requests,
|
|
120
|
+
quotas.freeToolCalls.limit,
|
|
121
|
+
),
|
|
57
122
|
resetsAt: new Date(quotas.freeToolCalls.renewsAt),
|
|
58
123
|
windowSeconds: 24 * 60 * 60,
|
|
59
124
|
usedValue: quotas.freeToolCalls.requests,
|
|
@@ -155,6 +220,34 @@ function renderProgressBar(
|
|
|
155
220
|
return parts.join("");
|
|
156
221
|
}
|
|
157
222
|
|
|
223
|
+
function renderSimpleIndicatorBar(
|
|
224
|
+
usedPercent: number,
|
|
225
|
+
width: number,
|
|
226
|
+
theme: Theme,
|
|
227
|
+
severity: "success" | "warning" | "error",
|
|
228
|
+
): string {
|
|
229
|
+
const clampedPercent = Math.max(0, Math.min(100, usedPercent));
|
|
230
|
+
// Clamp to width - 1 to avoid off-by-one when usedPercent === 100
|
|
231
|
+
const usedIndex = Math.min(
|
|
232
|
+
Math.round((clampedPercent / 100) * width),
|
|
233
|
+
width - 1,
|
|
234
|
+
);
|
|
235
|
+
const parts: string[] = [];
|
|
236
|
+
|
|
237
|
+
// Hide marker when within 5% of edges
|
|
238
|
+
const showMarker = clampedPercent >= 5 && clampedPercent <= 95;
|
|
239
|
+
|
|
240
|
+
for (let idx = 0; idx < width; idx++) {
|
|
241
|
+
if (showMarker && idx === usedIndex) {
|
|
242
|
+
parts.push(theme.fg(severity, "|"));
|
|
243
|
+
} else {
|
|
244
|
+
parts.push(theme.fg("dim", "░"));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return parts.join("");
|
|
249
|
+
}
|
|
250
|
+
|
|
158
251
|
export class QuotasComponent implements Component {
|
|
159
252
|
private state: QuotasState = { type: "loading" };
|
|
160
253
|
private theme: Theme;
|
|
@@ -254,22 +347,83 @@ export class QuotasComponent implements Component {
|
|
|
254
347
|
truncateToWidth(` ${theme.fg("accent", window.label)}`, maxWidth),
|
|
255
348
|
);
|
|
256
349
|
|
|
257
|
-
// Progress bar + usage
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
350
|
+
// Progress bar + usage (or indicator for new quota types)
|
|
351
|
+
if (window.isCredits || window.tickPercent !== undefined) {
|
|
352
|
+
// Show simple indicator bar for new quota types
|
|
353
|
+
const bar = renderSimpleIndicatorBar(
|
|
354
|
+
window.usedPercent,
|
|
355
|
+
barWidth,
|
|
356
|
+
theme,
|
|
357
|
+
severity,
|
|
358
|
+
);
|
|
359
|
+
const usedStr = window.isCredits
|
|
360
|
+
? `$${window.usedValue.toFixed(2)}/$${window.limitValue.toFixed(2)} (${Math.round(window.usedPercent)}%)`
|
|
361
|
+
: `${window.usedValue.toFixed(0)}/${window.limitValue.toFixed(0)} (${Math.round(window.usedPercent)}%)`;
|
|
362
|
+
const limitedBadge = window.isLimited
|
|
363
|
+
? theme.fg("error", " LIMITED")
|
|
364
|
+
: "";
|
|
365
|
+
lines.push(
|
|
366
|
+
truncateToWidth(
|
|
367
|
+
` ${bar} ${theme.fg(severity, usedStr)}${limitedBadge}`,
|
|
368
|
+
maxWidth,
|
|
369
|
+
),
|
|
370
|
+
);
|
|
371
|
+
} else {
|
|
372
|
+
// Traditional progress bar for legacy quota types
|
|
373
|
+
const bar = renderProgressBar(
|
|
374
|
+
window.usedPercent,
|
|
375
|
+
barWidth,
|
|
376
|
+
theme,
|
|
377
|
+
severity,
|
|
378
|
+
pacePercent,
|
|
379
|
+
);
|
|
380
|
+
const usedStr = `${window.usedValue.toLocaleString()}/${window.limitValue.toLocaleString()} (${Math.round(window.usedPercent)}%)`;
|
|
381
|
+
lines.push(
|
|
382
|
+
truncateToWidth(` ${bar} ${theme.fg(severity, usedStr)}`, maxWidth),
|
|
383
|
+
);
|
|
384
|
+
}
|
|
269
385
|
|
|
270
386
|
// Metadata: estimated + pace left, reset time right
|
|
271
387
|
const leftParts: string[] = [];
|
|
272
|
-
|
|
388
|
+
|
|
389
|
+
// Show tick info for rolling window
|
|
390
|
+
if (window.tickPercent !== undefined) {
|
|
391
|
+
const now = Date.now();
|
|
392
|
+
const remainingMs = window.resetsAt.getTime() - now;
|
|
393
|
+
const remainingMins = Math.ceil(remainingMs / (1000 * 60));
|
|
394
|
+
const remainingSecs = Math.ceil(remainingMs / 1000);
|
|
395
|
+
const timeStr =
|
|
396
|
+
remainingMs <= 0
|
|
397
|
+
? "now"
|
|
398
|
+
: remainingMins >= 1
|
|
399
|
+
? `${remainingMins}m`
|
|
400
|
+
: `${remainingSecs}s`;
|
|
401
|
+
const tickValue = (window.tickPercent / 100) * window.limitValue;
|
|
402
|
+
const tickStr = `+${tickValue.toFixed(1)} in ${timeStr}`;
|
|
403
|
+
leftParts.push(theme.fg("dim", tickStr));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Show next regen credits for weekly token limit
|
|
407
|
+
if (window.nextRegenCredits !== undefined) {
|
|
408
|
+
const now = Date.now();
|
|
409
|
+
const remainingMs = window.resetsAt.getTime() - now;
|
|
410
|
+
const remainingHours = Math.ceil(remainingMs / (1000 * 60 * 60));
|
|
411
|
+
const remainingMins = Math.ceil(remainingMs / (1000 * 60));
|
|
412
|
+
const timeStr =
|
|
413
|
+
remainingMs <= 0
|
|
414
|
+
? "now"
|
|
415
|
+
: remainingHours >= 1
|
|
416
|
+
? `${remainingHours}h`
|
|
417
|
+
: `${remainingMins}m`;
|
|
418
|
+
const regenStr = `+${window.nextRegenCredits} in ${timeStr}`;
|
|
419
|
+
leftParts.push(theme.fg("dim", regenStr));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (
|
|
423
|
+
projectedPercent > 0 &&
|
|
424
|
+
window.tickPercent === undefined &&
|
|
425
|
+
window.nextRegenCredits === undefined
|
|
426
|
+
) {
|
|
273
427
|
const estStr = `est ${Math.round(projectedPercent)}%`;
|
|
274
428
|
leftParts.push(
|
|
275
429
|
severity !== "success"
|
|
@@ -278,7 +432,11 @@ export class QuotasComponent implements Component {
|
|
|
278
432
|
);
|
|
279
433
|
}
|
|
280
434
|
|
|
281
|
-
if (
|
|
435
|
+
if (
|
|
436
|
+
pacePercent !== null &&
|
|
437
|
+
window.tickPercent === undefined &&
|
|
438
|
+
window.nextRegenCredits === undefined
|
|
439
|
+
) {
|
|
282
440
|
const paceDiff = window.usedPercent - pacePercent;
|
|
283
441
|
if (Math.abs(paceDiff) > 5) {
|
|
284
442
|
if (paceDiff > 0) {
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { hasSyntheticApiKey } from "../../lib/env";
|
|
3
2
|
import { registerQuotasCommand } from "./command";
|
|
4
3
|
import { registerSubIntegration } from "./sub-integration";
|
|
5
4
|
|
|
6
5
|
export default async function (pi: ExtensionAPI) {
|
|
7
|
-
if (!hasSyntheticApiKey()) {
|
|
8
|
-
return;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
6
|
registerQuotasCommand(pi);
|
|
12
7
|
registerSubIntegration(pi);
|
|
13
8
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
1
|
+
import type { AuthStorage, ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { getSyntheticApiKey } from "../../lib/env";
|
|
2
3
|
import type { QuotasResponse } from "../../types/quotas";
|
|
3
4
|
import { fetchQuotas, formatResetTime } from "../../utils/quotas";
|
|
4
5
|
|
|
@@ -27,34 +28,66 @@ interface SubCoreSettingsPayload {
|
|
|
27
28
|
function toUsageSnapshot(quotas: QuotasResponse): UsageSnapshot {
|
|
28
29
|
const windows: RateWindow[] = [];
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
// Weekly token limit (credits-based)
|
|
32
|
+
if (quotas.weeklyTokenLimit) {
|
|
33
|
+
const { weeklyTokenLimit } = quotas;
|
|
34
|
+
windows.push({
|
|
35
|
+
label: "Credits",
|
|
36
|
+
usedPercent: Math.round(
|
|
37
|
+
Math.max(0, Math.min(100, 100 - weeklyTokenLimit.percentRemaining)),
|
|
38
|
+
),
|
|
39
|
+
resetDescription: formatResetTime(weeklyTokenLimit.nextRegenAt),
|
|
40
|
+
resetAt: weeklyTokenLimit.nextRegenAt,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Rolling 5-hour limit (request-based)
|
|
45
|
+
if (quotas.rollingFiveHourLimit && quotas.rollingFiveHourLimit.max > 0) {
|
|
46
|
+
const { rollingFiveHourLimit } = quotas;
|
|
47
|
+
const used = rollingFiveHourLimit.max - rollingFiveHourLimit.remaining;
|
|
48
|
+
windows.push({
|
|
49
|
+
label: "5h",
|
|
50
|
+
usedPercent: Math.round(
|
|
51
|
+
Math.max(0, Math.min(100, (used / rollingFiveHourLimit.max) * 100)),
|
|
52
|
+
),
|
|
53
|
+
resetDescription: formatResetTime(rollingFiveHourLimit.nextTickAt),
|
|
54
|
+
resetAt: rollingFiveHourLimit.nextTickAt,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Legacy subscription (fallback if rollingFiveHourLimit not available)
|
|
59
|
+
if (
|
|
60
|
+
!quotas.rollingFiveHourLimit &&
|
|
61
|
+
quotas.subscription?.limit &&
|
|
62
|
+
quotas.subscription.limit > 0
|
|
63
|
+
) {
|
|
31
64
|
const pct =
|
|
32
65
|
(quotas.subscription.requests / quotas.subscription.limit) * 100;
|
|
33
66
|
windows.push({
|
|
34
67
|
label: "5h",
|
|
35
|
-
usedPercent: Math.round(pct),
|
|
68
|
+
usedPercent: Math.round(Math.max(0, Math.min(100, pct))),
|
|
36
69
|
resetDescription: formatResetTime(quotas.subscription.renewsAt),
|
|
37
70
|
resetAt: quotas.subscription.renewsAt,
|
|
38
71
|
});
|
|
39
72
|
}
|
|
40
73
|
|
|
41
|
-
if (quotas.search?.hourly) {
|
|
74
|
+
if (quotas.search?.hourly?.limit && quotas.search.hourly.limit > 0) {
|
|
42
75
|
const pct =
|
|
43
76
|
(quotas.search.hourly.requests / quotas.search.hourly.limit) * 100;
|
|
44
77
|
windows.push({
|
|
45
78
|
label: "Search",
|
|
46
|
-
usedPercent: Math.round(pct),
|
|
79
|
+
usedPercent: Math.round(Math.max(0, Math.min(100, pct))),
|
|
47
80
|
resetDescription: formatResetTime(quotas.search.hourly.renewsAt),
|
|
48
81
|
resetAt: quotas.search.hourly.renewsAt,
|
|
49
82
|
});
|
|
50
83
|
}
|
|
51
84
|
|
|
52
|
-
if (quotas.freeToolCalls) {
|
|
85
|
+
if (quotas.freeToolCalls?.limit && quotas.freeToolCalls.limit > 0) {
|
|
53
86
|
const pct =
|
|
54
87
|
(quotas.freeToolCalls.requests / quotas.freeToolCalls.limit) * 100;
|
|
55
88
|
windows.push({
|
|
56
89
|
label: "Tools",
|
|
57
|
-
usedPercent: Math.round(pct),
|
|
90
|
+
usedPercent: Math.round(Math.max(0, Math.min(100, pct))),
|
|
58
91
|
resetDescription: formatResetTime(quotas.freeToolCalls.renewsAt),
|
|
59
92
|
resetAt: quotas.freeToolCalls.renewsAt,
|
|
60
93
|
});
|
|
@@ -68,8 +101,13 @@ function toUsageSnapshot(quotas: QuotasResponse): UsageSnapshot {
|
|
|
68
101
|
};
|
|
69
102
|
}
|
|
70
103
|
|
|
71
|
-
async function emitCurrentUsage(
|
|
72
|
-
|
|
104
|
+
async function emitCurrentUsage(
|
|
105
|
+
pi: ExtensionAPI,
|
|
106
|
+
authStorage: AuthStorage,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
const apiKey = await getSyntheticApiKey(authStorage);
|
|
109
|
+
if (!apiKey) return;
|
|
110
|
+
const quotas = await fetchQuotas(apiKey);
|
|
73
111
|
if (!quotas) return;
|
|
74
112
|
pi.events.emit("sub-core:update-current", {
|
|
75
113
|
state: { provider: "synthetic", usage: toUsageSnapshot(quotas) },
|
|
@@ -77,12 +115,11 @@ async function emitCurrentUsage(pi: ExtensionAPI): Promise<void> {
|
|
|
77
115
|
}
|
|
78
116
|
|
|
79
117
|
export function registerSubIntegration(pi: ExtensionAPI): void {
|
|
80
|
-
if (!process.env.SYNTHETIC_API_KEY) return;
|
|
81
|
-
|
|
82
118
|
let interval: NodeJS.Timeout | undefined;
|
|
83
119
|
let refreshMs = 60000;
|
|
84
120
|
let subCoreReady = false;
|
|
85
121
|
let currentProvider: string | undefined;
|
|
122
|
+
let currentAuthStorage: AuthStorage | undefined;
|
|
86
123
|
|
|
87
124
|
function isSynthetic(): boolean {
|
|
88
125
|
return currentProvider === "synthetic";
|
|
@@ -95,15 +132,15 @@ export function registerSubIntegration(pi: ExtensionAPI): void {
|
|
|
95
132
|
}
|
|
96
133
|
}
|
|
97
134
|
|
|
98
|
-
function
|
|
135
|
+
function startPolling(authStorage: AuthStorage): void {
|
|
99
136
|
stop();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
emitCurrentUsage(pi);
|
|
137
|
+
currentAuthStorage = authStorage;
|
|
138
|
+
void emitCurrentUsage(pi, authStorage);
|
|
104
139
|
const ms = Math.max(10000, refreshMs);
|
|
105
140
|
interval = setInterval(() => {
|
|
106
|
-
if (isSynthetic())
|
|
141
|
+
if (isSynthetic() && currentAuthStorage) {
|
|
142
|
+
void emitCurrentUsage(pi, currentAuthStorage);
|
|
143
|
+
}
|
|
107
144
|
}, ms);
|
|
108
145
|
interval.unref?.();
|
|
109
146
|
}
|
|
@@ -111,28 +148,44 @@ export function registerSubIntegration(pi: ExtensionAPI): void {
|
|
|
111
148
|
// Custom events (inter-extension bus)
|
|
112
149
|
pi.events.on("sub-core:ready", () => {
|
|
113
150
|
subCoreReady = true;
|
|
114
|
-
|
|
151
|
+
// Polling starts in session_start/model_select when provider is synthetic
|
|
115
152
|
});
|
|
116
153
|
|
|
117
154
|
pi.events.on("sub-core:settings:updated", (data: unknown) => {
|
|
118
155
|
const payload = data as SubCoreSettingsPayload;
|
|
119
156
|
if (payload.settings?.behavior?.refreshInterval) {
|
|
120
157
|
refreshMs = payload.settings.behavior.refreshInterval * 1000;
|
|
121
|
-
|
|
158
|
+
// Restart with new interval if currently running
|
|
159
|
+
if (interval && isSynthetic() && currentAuthStorage) {
|
|
160
|
+
startPolling(currentAuthStorage);
|
|
161
|
+
}
|
|
122
162
|
}
|
|
123
163
|
});
|
|
124
164
|
|
|
125
|
-
// Lifecycle events
|
|
126
|
-
pi.on("session_start", (_event, ctx) => {
|
|
165
|
+
// Lifecycle events
|
|
166
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
127
167
|
currentProvider = ctx.model?.provider;
|
|
128
|
-
|
|
168
|
+
currentAuthStorage = ctx.modelRegistry.authStorage;
|
|
169
|
+
|
|
170
|
+
if (subCoreReady && isSynthetic()) {
|
|
171
|
+
const apiKey = await getSyntheticApiKey(currentAuthStorage);
|
|
172
|
+
if (apiKey) {
|
|
173
|
+
startPolling(currentAuthStorage);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
129
176
|
});
|
|
130
177
|
|
|
131
|
-
pi.on("model_select", (event,
|
|
178
|
+
pi.on("model_select", async (event, ctx) => {
|
|
132
179
|
currentProvider = event.model?.provider;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
180
|
+
currentAuthStorage = ctx.modelRegistry.authStorage;
|
|
181
|
+
|
|
182
|
+
if (subCoreReady && isSynthetic()) {
|
|
183
|
+
const apiKey = await getSyntheticApiKey(currentAuthStorage);
|
|
184
|
+
if (apiKey) {
|
|
185
|
+
startPolling(currentAuthStorage);
|
|
186
|
+
} else {
|
|
187
|
+
stop();
|
|
188
|
+
}
|
|
136
189
|
} else {
|
|
137
190
|
stop();
|
|
138
191
|
}
|
|
@@ -140,6 +193,7 @@ export function registerSubIntegration(pi: ExtensionAPI): void {
|
|
|
140
193
|
|
|
141
194
|
pi.on("session_shutdown", () => {
|
|
142
195
|
currentProvider = undefined;
|
|
196
|
+
currentAuthStorage = undefined;
|
|
143
197
|
stop();
|
|
144
198
|
});
|
|
145
199
|
}
|
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { hasSyntheticApiKey } from "../../lib/env";
|
|
3
|
-
import { registerSyntheticWebSearchHooks } from "./hooks";
|
|
4
2
|
import { registerSyntheticWebSearchTool } from "./tool";
|
|
5
3
|
|
|
6
4
|
export default async function (pi: ExtensionAPI) {
|
|
7
|
-
if (!hasSyntheticApiKey()) {
|
|
8
|
-
return;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
5
|
registerSyntheticWebSearchTool(pi);
|
|
12
|
-
registerSyntheticWebSearchHooks(pi);
|
|
13
6
|
}
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
10
10
|
import { Container, Markdown, Text } from "@mariozechner/pi-tui";
|
|
11
11
|
import { type Static, Type } from "@sinclair/typebox";
|
|
12
|
+
import { getSyntheticApiKey } from "../../lib/env";
|
|
12
13
|
|
|
13
14
|
export const SYNTHETIC_WEB_SEARCH_TOOL = "synthetic_web_search" as const;
|
|
14
15
|
|
|
@@ -51,16 +52,18 @@ export function registerSyntheticWebSearchTool(pi: ExtensionAPI): void {
|
|
|
51
52
|
onUpdate:
|
|
52
53
|
| ((result: AgentToolResult<WebSearchDetails>) => void)
|
|
53
54
|
| undefined,
|
|
54
|
-
|
|
55
|
+
ctx: ExtensionContext,
|
|
55
56
|
): Promise<AgentToolResult<WebSearchDetails>> {
|
|
56
57
|
onUpdate?.({
|
|
57
58
|
content: [{ type: "text", text: "Searching..." }],
|
|
58
59
|
details: { query: params.query },
|
|
59
60
|
});
|
|
60
61
|
|
|
61
|
-
const apiKey =
|
|
62
|
+
const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
|
|
62
63
|
if (!apiKey) {
|
|
63
|
-
throw new Error(
|
|
64
|
+
throw new Error(
|
|
65
|
+
"Synthetic web search requires a Synthetic subscription. Add credentials to ~/.pi/agent/auth.json or set SYNTHETIC_API_KEY environment variable.",
|
|
66
|
+
);
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
const response = await fetch("https://api.synthetic.new/v2/search", {
|
package/src/lib/env.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import type { AuthStorage } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const PROVIDER_ID = "synthetic";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Get the Synthetic API key through Pi's auth handling.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order:
|
|
9
|
+
* 1. Runtime override (CLI --api-key)
|
|
10
|
+
* 2. auth.json entry for "synthetic"
|
|
11
|
+
* 3. Environment variable SYNTHETIC_API_KEY
|
|
12
|
+
*/
|
|
13
|
+
export async function getSyntheticApiKey(
|
|
14
|
+
authStorage: AuthStorage,
|
|
15
|
+
): Promise<string | undefined> {
|
|
16
|
+
const key = await authStorage.getApiKey(PROVIDER_ID);
|
|
17
|
+
return key ?? process.env.SYNTHETIC_API_KEY;
|
|
7
18
|
}
|
package/src/types/quotas.ts
CHANGED
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
export interface QuotasResponse {
|
|
2
|
-
subscription
|
|
2
|
+
subscription?: {
|
|
3
3
|
limit: number;
|
|
4
4
|
requests: number;
|
|
5
5
|
renewsAt: string;
|
|
6
6
|
};
|
|
7
|
-
search
|
|
8
|
-
hourly
|
|
7
|
+
search?: {
|
|
8
|
+
hourly?: {
|
|
9
9
|
limit: number;
|
|
10
10
|
requests: number;
|
|
11
11
|
renewsAt: string;
|
|
12
12
|
};
|
|
13
13
|
};
|
|
14
|
-
freeToolCalls
|
|
14
|
+
freeToolCalls?: {
|
|
15
15
|
limit: number;
|
|
16
16
|
requests: number;
|
|
17
17
|
renewsAt: string;
|
|
18
18
|
};
|
|
19
|
+
weeklyTokenLimit?: {
|
|
20
|
+
nextRegenAt: string;
|
|
21
|
+
percentRemaining: number;
|
|
22
|
+
maxCredits: string;
|
|
23
|
+
remainingCredits: string;
|
|
24
|
+
nextRegenCredits: string;
|
|
25
|
+
};
|
|
26
|
+
rollingFiveHourLimit?: {
|
|
27
|
+
nextTickAt: string;
|
|
28
|
+
tickPercent: number;
|
|
29
|
+
remaining: number;
|
|
30
|
+
max: number;
|
|
31
|
+
limited: boolean;
|
|
32
|
+
};
|
|
19
33
|
}
|
package/src/utils/quotas.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import type { QuotasResponse } from "../types/quotas";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
if (!
|
|
3
|
+
export async function fetchQuotas(
|
|
4
|
+
apiKey: string,
|
|
5
|
+
): Promise<QuotasResponse | null> {
|
|
6
|
+
if (!apiKey) return null;
|
|
7
7
|
|
|
8
8
|
try {
|
|
9
9
|
const response = await fetch("https://api.synthetic.new/v2/quotas", {
|
|
10
|
-
headers: { Authorization: `Bearer ${
|
|
10
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
if (!response.ok) return null;
|
|
14
|
-
|
|
14
|
+
const data: QuotasResponse = await response.json();
|
|
15
|
+
return data;
|
|
15
16
|
} catch {
|
|
16
17
|
return null;
|
|
17
18
|
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { SYNTHETIC_WEB_SEARCH_TOOL } from "./tool";
|
|
3
|
-
|
|
4
|
-
async function checkSubscriptionAccess(
|
|
5
|
-
apiKey: string,
|
|
6
|
-
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
7
|
-
try {
|
|
8
|
-
const response = await fetch("https://api.synthetic.new/v2/quotas", {
|
|
9
|
-
method: "GET",
|
|
10
|
-
headers: {
|
|
11
|
-
Authorization: `Bearer ${apiKey}`,
|
|
12
|
-
},
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
if (!response.ok) {
|
|
16
|
-
return {
|
|
17
|
-
ok: false,
|
|
18
|
-
reason: `Quotas check failed (HTTP ${response.status})`,
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const data = await response.json();
|
|
23
|
-
if (data?.subscription?.limit > 0) {
|
|
24
|
-
return { ok: true };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
ok: false,
|
|
29
|
-
reason: "No active subscription (search requires a subscription plan)",
|
|
30
|
-
};
|
|
31
|
-
} catch (error) {
|
|
32
|
-
const message =
|
|
33
|
-
error instanceof Error ? error.message : "Unknown error occurred";
|
|
34
|
-
return { ok: false, reason: `Quotas check failed: ${message}` };
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function registerSyntheticWebSearchHooks(pi: ExtensionAPI): void {
|
|
39
|
-
let accessCheckPromise:
|
|
40
|
-
| Promise<{ ok: true } | { ok: false; reason: string }>
|
|
41
|
-
| undefined;
|
|
42
|
-
let hasAccess = false;
|
|
43
|
-
let deniedReason: string | undefined;
|
|
44
|
-
let didNotifyDenied = false;
|
|
45
|
-
|
|
46
|
-
// Keep tool inactive at session start. Availability is decided before each agent run.
|
|
47
|
-
pi.on("session_start", () => {
|
|
48
|
-
const current = pi.getActiveTools();
|
|
49
|
-
if (current.includes(SYNTHETIC_WEB_SEARCH_TOOL)) {
|
|
50
|
-
pi.setActiveTools(
|
|
51
|
-
current.filter((toolName) => toolName !== SYNTHETIC_WEB_SEARCH_TOOL),
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// Verify subscription only when user starts agent execution.
|
|
57
|
-
pi.on("before_agent_start", async (_event, ctx) => {
|
|
58
|
-
const apiKey = process.env.SYNTHETIC_API_KEY;
|
|
59
|
-
if (!apiKey) {
|
|
60
|
-
hasAccess = false;
|
|
61
|
-
deniedReason = "SYNTHETIC_API_KEY is not configured";
|
|
62
|
-
accessCheckPromise = undefined;
|
|
63
|
-
} else {
|
|
64
|
-
if (deniedReason === "SYNTHETIC_API_KEY is not configured") {
|
|
65
|
-
deniedReason = undefined;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (!hasAccess && !deniedReason) {
|
|
69
|
-
accessCheckPromise ??= checkSubscriptionAccess(apiKey);
|
|
70
|
-
const access = await accessCheckPromise;
|
|
71
|
-
|
|
72
|
-
if (!access.ok) {
|
|
73
|
-
deniedReason = access.reason;
|
|
74
|
-
} else {
|
|
75
|
-
hasAccess = true;
|
|
76
|
-
didNotifyDenied = false;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (deniedReason) {
|
|
82
|
-
const current = pi.getActiveTools();
|
|
83
|
-
if (current.includes(SYNTHETIC_WEB_SEARCH_TOOL)) {
|
|
84
|
-
pi.setActiveTools(
|
|
85
|
-
current.filter((toolName) => toolName !== SYNTHETIC_WEB_SEARCH_TOOL),
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (ctx.hasUI && !didNotifyDenied) {
|
|
90
|
-
ctx.ui.notify(
|
|
91
|
-
`Synthetic web search disabled: ${deniedReason}`,
|
|
92
|
-
"warning",
|
|
93
|
-
);
|
|
94
|
-
didNotifyDenied = true;
|
|
95
|
-
}
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const current = pi.getActiveTools();
|
|
100
|
-
if (!current.includes(SYNTHETIC_WEB_SEARCH_TOOL)) {
|
|
101
|
-
pi.setActiveTools([...current, SYNTHETIC_WEB_SEARCH_TOOL]);
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
}
|