@aliou/pi-synthetic 0.8.4 → 0.9.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.4",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -15,7 +15,9 @@
15
15
  },
16
16
  "pi": {
17
17
  "extensions": [
18
- "./src/index.ts"
18
+ "./src/extensions/provider/index.ts",
19
+ "./src/extensions/web-search/index.ts",
20
+ "./src/extensions/command-quotas/index.ts"
19
21
  ],
20
22
  "video": "https://assets.aliou.me/pi-extensions/demos/pi-synthetic.mp4"
21
23
  },
@@ -34,7 +36,7 @@
34
36
  "@aliou/pi-utils-ui": "^0.1.2"
35
37
  },
36
38
  "devDependencies": {
37
- "@aliou/biome-plugins": "^0.3.2",
39
+ "@aliou/biome-plugins": "^0.7.0",
38
40
  "@biomejs/biome": "^2.4.2",
39
41
  "@changesets/cli": "^2.27.11",
40
42
  "@mariozechner/pi-coding-agent": "0.61.0",
@@ -0,0 +1,65 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { getSyntheticApiKey } from "../../lib/env";
3
+ import { fetchQuotas } from "../../utils/quotas";
4
+ import { QuotasComponent } from "./components/quotas-display";
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
+
9
+ export function registerQuotasCommand(pi: ExtensionAPI): void {
10
+ pi.registerCommand("synthetic:quotas", {
11
+ description: "Display Synthetic API usage quotas",
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
+
19
+ const result = await ctx.ui.custom<null>((tui, theme, _kb, done) => {
20
+ const component = new QuotasComponent(theme, () => done(null));
21
+
22
+ fetchQuotas(apiKey)
23
+ .then((quotas) => {
24
+ if (!quotas) {
25
+ component.setState({
26
+ type: "error",
27
+ message:
28
+ "Failed to fetch quotas. Check your Synthetic subscription status.",
29
+ });
30
+ } else {
31
+ component.setState({ type: "loaded", quotas });
32
+ }
33
+ tui.requestRender();
34
+ })
35
+ .catch(() => {
36
+ component.setState({
37
+ type: "error",
38
+ message:
39
+ "Failed to fetch quotas. Check your Synthetic subscription status.",
40
+ });
41
+ tui.requestRender();
42
+ });
43
+
44
+ return {
45
+ render: (width: number) => component.render(width),
46
+ invalidate: () => component.invalidate(),
47
+ handleInput: (data: string) => component.handleInput(data),
48
+ };
49
+ });
50
+
51
+ // RPC fallback: return JSON
52
+ if (result === undefined) {
53
+ const quotas = await fetchQuotas(apiKey);
54
+ if (!quotas) {
55
+ ctx.ui.notify(
56
+ JSON.stringify({ error: "Failed to fetch quotas" }),
57
+ "error",
58
+ );
59
+ return;
60
+ }
61
+ ctx.ui.notify(JSON.stringify(quotas, null, 2), "info");
62
+ }
63
+ },
64
+ });
65
+ }
@@ -0,0 +1,314 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
+ import type { Component } from "@mariozechner/pi-tui";
4
+ import {
5
+ matchesKey,
6
+ truncateToWidth,
7
+ visibleWidth,
8
+ } from "@mariozechner/pi-tui";
9
+ import type { QuotasResponse } from "../../../types/quotas";
10
+
11
+ type QuotasState =
12
+ | { type: "loading" }
13
+ | { type: "error"; message: string }
14
+ | { type: "loaded"; quotas: QuotasResponse };
15
+
16
+ interface QuotaWindow {
17
+ label: string;
18
+ usedPercent: number;
19
+ resetsAt: Date;
20
+ windowSeconds: number;
21
+ usedValue: number;
22
+ limitValue: number;
23
+ }
24
+
25
+ function toWindows(quotas: QuotasResponse): QuotaWindow[] {
26
+ const windows: QuotaWindow[] = [];
27
+
28
+ if (quotas.subscription.limit > 0) {
29
+ windows.push({
30
+ label: "Completions",
31
+ usedPercent:
32
+ (quotas.subscription.requests / quotas.subscription.limit) * 100,
33
+ resetsAt: new Date(quotas.subscription.renewsAt),
34
+ windowSeconds: 5 * 60 * 60,
35
+ usedValue: quotas.subscription.requests,
36
+ limitValue: quotas.subscription.limit,
37
+ });
38
+ }
39
+
40
+ if (quotas.search.hourly.limit > 0) {
41
+ windows.push({
42
+ label: "Search",
43
+ usedPercent:
44
+ (quotas.search.hourly.requests / quotas.search.hourly.limit) * 100,
45
+ resetsAt: new Date(quotas.search.hourly.renewsAt),
46
+ windowSeconds: 60 * 60,
47
+ usedValue: quotas.search.hourly.requests,
48
+ limitValue: quotas.search.hourly.limit,
49
+ });
50
+ }
51
+
52
+ if (quotas.freeToolCalls.limit > 0) {
53
+ windows.push({
54
+ label: "Free Tool Calls",
55
+ usedPercent:
56
+ (quotas.freeToolCalls.requests / quotas.freeToolCalls.limit) * 100,
57
+ resetsAt: new Date(quotas.freeToolCalls.renewsAt),
58
+ windowSeconds: 24 * 60 * 60,
59
+ usedValue: quotas.freeToolCalls.requests,
60
+ limitValue: quotas.freeToolCalls.limit,
61
+ });
62
+ }
63
+
64
+ return windows;
65
+ }
66
+
67
+ function getPacePercent(window: QuotaWindow): number | null {
68
+ const totalMs = window.windowSeconds * 1000;
69
+ if (totalMs <= 0) return null;
70
+ const remainingMs = window.resetsAt.getTime() - Date.now();
71
+ const elapsedMs = totalMs - remainingMs;
72
+ return Math.max(0, Math.min(100, (elapsedMs / totalMs) * 100));
73
+ }
74
+
75
+ function getProjectedPercent(
76
+ usedPercent: number,
77
+ pacePercent: number | null,
78
+ ): number {
79
+ if (pacePercent === null) return usedPercent;
80
+ const effectivePace = Math.max(5, pacePercent);
81
+ return Math.max(0, (usedPercent / effectivePace) * 100);
82
+ }
83
+
84
+ function getSeverity(
85
+ projectedPercent: number,
86
+ pacePercent: number | null,
87
+ ): "success" | "warning" | "error" {
88
+ if (pacePercent === null) {
89
+ if (projectedPercent >= 100) return "error";
90
+ if (projectedPercent >= 90) return "warning";
91
+ return "success";
92
+ }
93
+ // Dynamic thresholds based on window progress
94
+ const progress = pacePercent / 100;
95
+ const warnThreshold = 260 - (260 - 120) * progress;
96
+ const highThreshold = 320 - (320 - 145) * progress;
97
+ const criticalThreshold = 400 - (400 - 170) * progress;
98
+
99
+ if (projectedPercent >= criticalThreshold) return "error";
100
+ if (projectedPercent >= highThreshold) return "error";
101
+ if (projectedPercent >= warnThreshold) return "warning";
102
+ return "success";
103
+ }
104
+
105
+ function formatResetDateTime(date: Date): string {
106
+ const now = new Date();
107
+ const isToday =
108
+ date.getDate() === now.getDate() &&
109
+ date.getMonth() === now.getMonth() &&
110
+ date.getFullYear() === now.getFullYear();
111
+
112
+ const timeStr = date.toLocaleTimeString("en-US", {
113
+ hour: "numeric",
114
+ minute: "2-digit",
115
+ hour12: true,
116
+ });
117
+
118
+ if (isToday) {
119
+ return `today ${timeStr}`;
120
+ }
121
+
122
+ const dateStr = date.toLocaleDateString("en-US", {
123
+ month: "short",
124
+ day: "numeric",
125
+ });
126
+
127
+ return `${dateStr} ${timeStr}`;
128
+ }
129
+
130
+ function renderProgressBar(
131
+ percent: number,
132
+ width: number,
133
+ theme: Theme,
134
+ fillColor: "success" | "warning" | "error",
135
+ pacePercent?: number | null,
136
+ ): string {
137
+ const clamped = Math.max(0, Math.min(100, Math.round(percent)));
138
+ const filled = Math.round((clamped / 100) * width);
139
+ const paceIndex =
140
+ pacePercent === null || pacePercent === undefined || pacePercent <= percent
141
+ ? null
142
+ : Math.round((Math.max(0, Math.min(100, pacePercent)) / 100) * width);
143
+
144
+ const parts: string[] = [];
145
+ for (let idx = 0; idx < width; idx++) {
146
+ if (idx < filled) {
147
+ parts.push(theme.fg(fillColor, "█"));
148
+ } else if (paceIndex !== null && idx < paceIndex) {
149
+ parts.push(theme.fg(fillColor, "▓"));
150
+ } else {
151
+ parts.push(theme.fg("dim", "░"));
152
+ }
153
+ }
154
+
155
+ return parts.join("");
156
+ }
157
+
158
+ export class QuotasComponent implements Component {
159
+ private state: QuotasState = { type: "loading" };
160
+ private theme: Theme;
161
+ private onClose: () => void;
162
+
163
+ constructor(theme: Theme, onClose: () => void) {
164
+ this.theme = theme;
165
+ this.onClose = onClose;
166
+ }
167
+
168
+ setState(state: QuotasState): void {
169
+ this.state = state;
170
+ }
171
+
172
+ handleInput(data: string): boolean {
173
+ if (matchesKey(data, "escape") || data === "q") {
174
+ this.onClose();
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+
180
+ render(width: number): string[] {
181
+ const lines: string[] = [];
182
+ const border = new DynamicBorder((s: string) => this.theme.fg("border", s));
183
+ const contentWidth = Math.max(1, width - 4);
184
+
185
+ lines.push(...border.render(width));
186
+ lines.push(
187
+ truncateToWidth(
188
+ ` ${this.theme.fg("accent", this.theme.bold("Synthetic API Quotas"))}`,
189
+ width,
190
+ ),
191
+ );
192
+ lines.push("");
193
+
194
+ switch (this.state.type) {
195
+ case "loading":
196
+ lines.push(this.theme.fg("muted", " Loading..."));
197
+ break;
198
+ case "error":
199
+ lines.push(this.theme.fg("error", ` ${this.state.message}`));
200
+ break;
201
+ case "loaded":
202
+ lines.push(
203
+ ...this.renderLoaded(this.state.quotas, contentWidth, width),
204
+ );
205
+ break;
206
+ }
207
+
208
+ lines.push("");
209
+ lines.push(this.theme.fg("dim", " q/Esc to close"));
210
+ lines.push(...border.render(width));
211
+
212
+ return lines;
213
+ }
214
+
215
+ private renderLoaded(
216
+ quotas: QuotasResponse,
217
+ contentWidth: number,
218
+ maxWidth: number,
219
+ ): string[] {
220
+ const lines: string[] = [];
221
+ const windows = toWindows(quotas);
222
+ const barWidth = Math.min(50, Math.max(20, contentWidth - 20));
223
+
224
+ for (const window of windows) {
225
+ lines.push(...this.renderWindow(window, barWidth, maxWidth));
226
+ lines.push("");
227
+ }
228
+
229
+ // Remove trailing empty line
230
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
231
+ lines.pop();
232
+ }
233
+
234
+ return lines;
235
+ }
236
+
237
+ private renderWindow(
238
+ window: QuotaWindow,
239
+ barWidth: number,
240
+ maxWidth: number,
241
+ ): string[] {
242
+ const lines: string[] = [];
243
+ const theme = this.theme;
244
+
245
+ const pacePercent = getPacePercent(window);
246
+ const projectedPercent = getProjectedPercent(
247
+ window.usedPercent,
248
+ pacePercent,
249
+ );
250
+ const severity = getSeverity(projectedPercent, pacePercent);
251
+
252
+ // Label
253
+ lines.push(
254
+ truncateToWidth(` ${theme.fg("accent", window.label)}`, maxWidth),
255
+ );
256
+
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
+ );
269
+
270
+ // Metadata: estimated + pace left, reset time right
271
+ const leftParts: string[] = [];
272
+ if (projectedPercent > 0) {
273
+ const estStr = `est ${Math.round(projectedPercent)}%`;
274
+ leftParts.push(
275
+ severity !== "success"
276
+ ? theme.fg(severity, estStr)
277
+ : theme.fg("dim", estStr),
278
+ );
279
+ }
280
+
281
+ if (pacePercent !== null) {
282
+ const paceDiff = window.usedPercent - pacePercent;
283
+ if (Math.abs(paceDiff) > 5) {
284
+ if (paceDiff > 0) {
285
+ leftParts.push(
286
+ theme.fg("warning", `${Math.round(Math.abs(paceDiff))}% ahead`),
287
+ );
288
+ } else {
289
+ leftParts.push(
290
+ theme.fg("success", `${Math.round(Math.abs(paceDiff))}% behind`),
291
+ );
292
+ }
293
+ }
294
+ }
295
+
296
+ const leftStr = leftParts.join(" ");
297
+ const resetStr = formatResetDateTime(window.resetsAt);
298
+ const rightStr = theme.fg("dim", resetStr);
299
+
300
+ const leftW = visibleWidth(leftStr);
301
+ const rightW = visibleWidth(rightStr);
302
+ const gap = Math.max(2, barWidth - leftW - rightW);
303
+
304
+ lines.push(
305
+ truncateToWidth(` ${leftStr}${" ".repeat(gap)}${rightStr}`, maxWidth),
306
+ );
307
+
308
+ return lines;
309
+ }
310
+
311
+ invalidate(): void {
312
+ // No internal cached state to invalidate
313
+ }
314
+ }
@@ -0,0 +1,8 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { registerQuotasCommand } from "./command";
3
+ import { registerSubIntegration } from "./sub-integration";
4
+
5
+ export default async function (pi: ExtensionAPI) {
6
+ registerQuotasCommand(pi);
7
+ registerSubIntegration(pi);
8
+ }
@@ -1,6 +1,7 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import type { QuotasResponse } from "../types/quotas";
3
- import { fetchQuotas, formatResetTime } from "../utils/quotas";
1
+ import type { AuthStorage, ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { getSyntheticApiKey } from "../../lib/env";
3
+ import type { QuotasResponse } from "../../types/quotas";
4
+ import { fetchQuotas, formatResetTime } from "../../utils/quotas";
4
5
 
5
6
  interface RateWindow {
6
7
  label: string;
@@ -53,7 +54,7 @@ function toUsageSnapshot(quotas: QuotasResponse): UsageSnapshot {
53
54
  const pct =
54
55
  (quotas.freeToolCalls.requests / quotas.freeToolCalls.limit) * 100;
55
56
  windows.push({
56
- label: "Free",
57
+ label: "Tools",
57
58
  usedPercent: Math.round(pct),
58
59
  resetDescription: formatResetTime(quotas.freeToolCalls.renewsAt),
59
60
  resetAt: quotas.freeToolCalls.renewsAt,
@@ -68,8 +69,13 @@ function toUsageSnapshot(quotas: QuotasResponse): UsageSnapshot {
68
69
  };
69
70
  }
70
71
 
71
- async function emitCurrentUsage(pi: ExtensionAPI): Promise<void> {
72
- const quotas = await fetchQuotas();
72
+ async function emitCurrentUsage(
73
+ pi: ExtensionAPI,
74
+ authStorage: AuthStorage,
75
+ ): Promise<void> {
76
+ const apiKey = await getSyntheticApiKey(authStorage);
77
+ if (!apiKey) return;
78
+ const quotas = await fetchQuotas(apiKey);
73
79
  if (!quotas) return;
74
80
  pi.events.emit("sub-core:update-current", {
75
81
  state: { provider: "synthetic", usage: toUsageSnapshot(quotas) },
@@ -77,12 +83,11 @@ async function emitCurrentUsage(pi: ExtensionAPI): Promise<void> {
77
83
  }
78
84
 
79
85
  export function registerSubIntegration(pi: ExtensionAPI): void {
80
- if (!process.env.SYNTHETIC_API_KEY) return;
81
-
82
86
  let interval: NodeJS.Timeout | undefined;
83
87
  let refreshMs = 60000;
84
88
  let subCoreReady = false;
85
89
  let currentProvider: string | undefined;
90
+ let currentAuthStorage: AuthStorage | undefined;
86
91
 
87
92
  function isSynthetic(): boolean {
88
93
  return currentProvider === "synthetic";
@@ -95,15 +100,15 @@ export function registerSubIntegration(pi: ExtensionAPI): void {
95
100
  }
96
101
  }
97
102
 
98
- function start(): void {
103
+ function startPolling(authStorage: AuthStorage): void {
99
104
  stop();
100
- if (!subCoreReady || !isSynthetic()) {
101
- return;
102
- }
103
- emitCurrentUsage(pi);
105
+ currentAuthStorage = authStorage;
106
+ void emitCurrentUsage(pi, authStorage);
104
107
  const ms = Math.max(10000, refreshMs);
105
108
  interval = setInterval(() => {
106
- if (isSynthetic()) emitCurrentUsage(pi);
109
+ if (isSynthetic() && currentAuthStorage) {
110
+ void emitCurrentUsage(pi, currentAuthStorage);
111
+ }
107
112
  }, ms);
108
113
  interval.unref?.();
109
114
  }
@@ -111,28 +116,44 @@ export function registerSubIntegration(pi: ExtensionAPI): void {
111
116
  // Custom events (inter-extension bus)
112
117
  pi.events.on("sub-core:ready", () => {
113
118
  subCoreReady = true;
114
- start();
119
+ // Polling starts in session_start/model_select when provider is synthetic
115
120
  });
116
121
 
117
122
  pi.events.on("sub-core:settings:updated", (data: unknown) => {
118
123
  const payload = data as SubCoreSettingsPayload;
119
124
  if (payload.settings?.behavior?.refreshInterval) {
120
125
  refreshMs = payload.settings.behavior.refreshInterval * 1000;
121
- if (interval) start();
126
+ // Restart with new interval if currently running
127
+ if (interval && isSynthetic() && currentAuthStorage) {
128
+ startPolling(currentAuthStorage);
129
+ }
122
130
  }
123
131
  });
124
132
 
125
- // Lifecycle events (pi.on, not pi.events.on)
126
- pi.on("session_start", (_event, ctx) => {
133
+ // Lifecycle events
134
+ pi.on("session_start", async (_event, ctx) => {
127
135
  currentProvider = ctx.model?.provider;
128
- start();
136
+ currentAuthStorage = ctx.modelRegistry.authStorage;
137
+
138
+ if (subCoreReady && isSynthetic()) {
139
+ const apiKey = await getSyntheticApiKey(currentAuthStorage);
140
+ if (apiKey) {
141
+ startPolling(currentAuthStorage);
142
+ }
143
+ }
129
144
  });
130
145
 
131
- pi.on("model_select", (event, _ctx) => {
146
+ pi.on("model_select", async (event, ctx) => {
132
147
  currentProvider = event.model?.provider;
133
- if (isSynthetic()) {
134
- emitCurrentUsage(pi);
135
- start();
148
+ currentAuthStorage = ctx.modelRegistry.authStorage;
149
+
150
+ if (subCoreReady && isSynthetic()) {
151
+ const apiKey = await getSyntheticApiKey(currentAuthStorage);
152
+ if (apiKey) {
153
+ startPolling(currentAuthStorage);
154
+ } else {
155
+ stop();
156
+ }
136
157
  } else {
137
158
  stop();
138
159
  }
@@ -140,6 +161,7 @@ export function registerSubIntegration(pi: ExtensionAPI): void {
140
161
 
141
162
  pi.on("session_shutdown", () => {
142
163
  currentProvider = undefined;
164
+ currentAuthStorage = undefined;
143
165
  stop();
144
166
  });
145
167
  }
@@ -26,3 +26,7 @@ export function registerSyntheticProvider(pi: ExtensionAPI): void {
26
26
  })),
27
27
  });
28
28
  }
29
+
30
+ export default async function (pi: ExtensionAPI) {
31
+ registerSyntheticProvider(pi);
32
+ }
@@ -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
  },
@@ -0,0 +1,6 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { registerSyntheticWebSearchTool } from "./tool";
3
+
4
+ export default async function (pi: ExtensionAPI) {
5
+ registerSyntheticWebSearchTool(pi);
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 ADDED
@@ -0,0 +1,18 @@
1
+ import type { AuthStorage } from "@mariozechner/pi-coding-agent";
2
+
3
+ const PROVIDER_ID = "synthetic";
4
+
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;
18
+ }