@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 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
- ### Set Environment Variable
11
+ ### Configure Credentials
12
12
 
13
- ```bash
14
- export SYNTHETIC_API_KEY="your-api-key-here"
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
- Add to shell profile for persistence:
21
+ Or set environment variable:
18
22
 
19
23
  ```bash
20
- echo 'export SYNTHETIC_API_KEY="your-api-key-here"' >> ~/.zshrc
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. Available when you have an active Synthetic subscription.
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
- - SYNTHETIC_API_KEY environment variable
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.8.5",
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.3.2",
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: "Failed to fetch quotas",
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: "Failed to fetch quotas",
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
- if (quotas.subscription.limit > 0) {
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
- (quotas.subscription.requests / quotas.subscription.limit) * 100,
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
- (quotas.search.hourly.requests / quotas.search.hourly.limit) * 100,
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
- (quotas.freeToolCalls.requests / quotas.freeToolCalls.limit) * 100,
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
- const bar = renderProgressBar(
259
- window.usedPercent,
260
- barWidth,
261
- theme,
262
- severity,
263
- pacePercent,
264
- );
265
- const usedStr = `${window.usedValue.toLocaleString()}/${window.limitValue.toLocaleString()} (${Math.round(window.usedPercent)}%)`;
266
- lines.push(
267
- truncateToWidth(` ${bar} ${theme.fg(severity, usedStr)}`, maxWidth),
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
- if (projectedPercent > 0) {
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 (pacePercent !== null) {
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
- if (quotas.subscription) {
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(pi: ExtensionAPI): Promise<void> {
72
- const quotas = await fetchQuotas();
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 start(): void {
135
+ function startPolling(authStorage: AuthStorage): void {
99
136
  stop();
100
- if (!subCoreReady || !isSynthetic()) {
101
- return;
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()) emitCurrentUsage(pi);
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
- start();
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
- if (interval) start();
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 (pi.on, not pi.events.on)
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
- start();
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, _ctx) => {
178
+ pi.on("model_select", async (event, ctx) => {
132
179
  currentProvider = event.model?.provider;
133
- if (isSynthetic()) {
134
- emitCurrentUsage(pi);
135
- start();
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
  }
@@ -42,7 +42,7 @@ async function fetchApiModels(): Promise<ApiModel[]> {
42
42
  );
43
43
  }
44
44
 
45
- const data = (await response.json()) as ApiResponse;
45
+ const data: ApiResponse = await response.json();
46
46
  return data.data;
47
47
  }
48
48
 
@@ -67,7 +67,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
67
67
  input: ["text"],
68
68
  cost: {
69
69
  input: 1,
70
- output: 6,
70
+ output: 3,
71
71
  cacheRead: 1,
72
72
  cacheWrite: 0,
73
73
  },
@@ -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
- _ctx: ExtensionContext,
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 = process.env.SYNTHETIC_API_KEY;
62
+ const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
62
63
  if (!apiKey) {
63
- throw new Error("SYNTHETIC_API_KEY is not configured");
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
- export function hasSyntheticApiKey(): boolean {
2
- return Boolean(process.env.SYNTHETIC_API_KEY);
3
- }
1
+ import type { AuthStorage } from "@mariozechner/pi-coding-agent";
2
+
3
+ const PROVIDER_ID = "synthetic";
4
4
 
5
- export function getSyntheticApiKey(): string {
6
- return process.env.SYNTHETIC_API_KEY || "";
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
  }
@@ -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
  }
@@ -1,17 +1,18 @@
1
1
  import type { QuotasResponse } from "../types/quotas";
2
2
 
3
- const API_KEY = process.env.SYNTHETIC_API_KEY;
4
-
5
- export async function fetchQuotas(): Promise<QuotasResponse | null> {
6
- if (!API_KEY) return null;
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 ${API_KEY}` },
10
+ headers: { Authorization: `Bearer ${apiKey}` },
11
11
  });
12
12
 
13
13
  if (!response.ok) return null;
14
- return (await response.json()) as QuotasResponse;
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
- }