@aliou/pi-synthetic 0.18.0 → 0.18.2

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
@@ -41,7 +41,7 @@ pi install npm:@aliou/pi-synthetic
41
41
  pi install git:github.com/aliou/pi-synthetic
42
42
 
43
43
  # Local development
44
- pi -e ./src/index.ts
44
+ pi -e .
45
45
  ```
46
46
 
47
47
  ## Usage
@@ -56,6 +56,8 @@ Once installed, select `synthetic` as your provider and choose from available mo
56
56
 
57
57
  All models are accessed through Synthetic's API. Some models are hosted by Synthetic directly (`provider: "synthetic"` in the model config); others are proxied by Synthetic to upstream backends such as Fireworks or Together.
58
58
 
59
+ Synthetic also provides permanent aliases (`syn:large:text`, `syn:small:text`, `syn:large:vision`, `syn:small:vision`) that route to the current best model for each category. These aliases are stable across model rotations — using an alias means no reconfiguration when models change. Alias models are always visible even when Proxied Models is disabled.
60
+
59
61
  By default, new installs show only Synthetic-hosted models. You can enable proxied models in `/synthetic:settings` under **Models > Proxied Models**. Existing configurations keep proxied models enabled to preserve prior behavior.
60
62
 
61
63
  The `provider` field in `src/extensions/provider/models.ts` is for maintenance only and is stripped before registering models with Pi, so users always select the `synthetic` provider.
@@ -109,13 +111,24 @@ The **Proxied Models** setting is not a loadable extension feature. It is a regu
109
111
 
110
112
  ## Adding or Updating Models
111
113
 
112
- Models are hardcoded in `src/extensions/provider/models.ts`. To add or update models:
114
+ Models are hardcoded in `src/extensions/provider/models.ts`. Entries are a union of concrete models and thin aliases (`syn:*` IDs).
115
+
116
+ ### Adding a concrete model
113
117
 
114
118
  1. Edit `src/extensions/provider/models.ts`
115
- 2. Add the model configuration following the `SyntheticModelConfig` interface
119
+ 2. Append a concrete model following the `SyntheticModelConfig` interface
116
120
  3. Set `provider` to the upstream backend Synthetic uses for that model, such as `synthetic`, `fireworks`, or `together`
117
121
  4. Run `pnpm run typecheck` to verify
118
122
 
123
+ ### Adding an alias model
124
+
125
+ 1. Add a thin `{ id, name, aliasFor }` entry at the top of `SYNTHETIC_MODELS`
126
+ 2. Set `id` and `name` from the Synthetic API
127
+ 3. Set `aliasFor` to `"hf:" + hugging_face_id` from the Synthetic API
128
+ 4. The resolved alias inherits all fields from the target at build time
129
+
130
+ When Synthetic changes which model an alias routes to, update only the `aliasFor` field.
131
+
119
132
  ## Development
120
133
 
121
134
  ### Setup
@@ -152,7 +165,7 @@ pnpm run test
152
165
  ### Test Locally
153
166
 
154
167
  ```bash
155
- pi -e ./src/index.ts
168
+ pi -e .
156
169
  ```
157
170
 
158
171
  ## Release
@@ -167,7 +180,7 @@ This repository uses [Changesets](https://github.com/changesets/changesets) for
167
180
 
168
181
  ## Requirements
169
182
 
170
- - Pi coding agent v0.72.0+
183
+ - Pi coding agent v0.77.0+
171
184
  - Synthetic API key (configured in `~/.pi/agent/auth.json` or via `SYNTHETIC_API_KEY`)
172
185
 
173
186
  ## Links
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-synthetic",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -33,8 +33,9 @@
33
33
  "!src/**/*.test.ts"
34
34
  ],
35
35
  "peerDependencies": {
36
- "@earendil-works/pi-coding-agent": "0.74.0",
37
- "@earendil-works/pi-tui": "0.74.0"
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "@earendil-works/pi-tui": "*",
38
+ "typebox": "*"
38
39
  },
39
40
  "dependencies": {
40
41
  "@aliou/pi-utils-settings": "^0.15.0",
@@ -44,12 +45,13 @@
44
45
  "@aliou/biome-plugins": "^0.8.1",
45
46
  "@biomejs/biome": "^2.4.15",
46
47
  "@changesets/cli": "^2.27.11",
47
- "@earendil-works/pi-coding-agent": "0.74.0",
48
+ "@earendil-works/pi-coding-agent": "0.77.0",
48
49
  "typebox": "^1.1.37",
49
50
  "@types/node": "^25.0.10",
50
51
  "husky": "^9.1.7",
51
52
  "typescript": "^5.9.3",
52
- "vitest": "^4.0.18"
53
+ "vitest": "^4.0.18",
54
+ "@earendil-works/pi-tui": "0.77.0"
53
55
  },
54
56
  "peerDependenciesMeta": {
55
57
  "@earendil-works/pi-coding-agent": {
@@ -57,6 +59,9 @@
57
59
  },
58
60
  "@earendil-works/pi-tui": {
59
61
  "optional": true
62
+ },
63
+ "typebox": {
64
+ "optional": true
60
65
  }
61
66
  },
62
67
  "scripts": {
@@ -28,19 +28,46 @@ import {
28
28
  } from "../../types/quotas";
29
29
  import { fetchQuotas } from "../../utils/quotas";
30
30
  import { SYNTHETIC_OVERFLOW_PATTERN } from "./context-overflow";
31
- import { SYNTHETIC_MODELS } from "./models";
31
+ import {
32
+ type ConcreteSyntheticModelConfig,
33
+ isAlias,
34
+ SYNTHETIC_MODELS,
35
+ } from "./models";
32
36
 
33
37
  export function buildSyntheticProviderModels(includeProxiedModels: boolean) {
34
- return SYNTHETIC_MODELS.filter(
35
- (model) => includeProxiedModels || model.provider === "synthetic",
36
- ).map(({ provider: _provider, ...model }) => ({
37
- ...model,
38
- compat: {
39
- supportsDeveloperRole: false,
40
- maxTokensField: "max_tokens" as const,
41
- ...model.compat,
42
- },
43
- }));
38
+ const concreteModels = SYNTHETIC_MODELS.filter(
39
+ (m): m is ConcreteSyntheticModelConfig => !isAlias(m),
40
+ );
41
+ const byId = new Map(concreteModels.map((m) => [m.id, m]));
42
+
43
+ const resolved = SYNTHETIC_MODELS.map((entry) => {
44
+ if (!isAlias(entry)) return entry;
45
+
46
+ const target = byId.get(entry.aliasFor);
47
+ if (!target) {
48
+ throw new Error(
49
+ `Synthetic alias "${entry.id}" references missing model "${entry.aliasFor}"`,
50
+ );
51
+ }
52
+
53
+ return {
54
+ ...target,
55
+ id: entry.id,
56
+ name: entry.name,
57
+ provider: "synthetic" as const,
58
+ };
59
+ });
60
+
61
+ return resolved
62
+ .filter((model) => includeProxiedModels || model.provider === "synthetic")
63
+ .map(({ provider: _provider, ...model }) => ({
64
+ ...model,
65
+ compat: {
66
+ supportsDeveloperRole: false,
67
+ maxTokensField: "max_tokens" as const,
68
+ ...model.compat,
69
+ },
70
+ }));
44
71
  }
45
72
 
46
73
  interface RegisterSyntheticProviderOptions {
@@ -53,7 +80,7 @@ export function registerSyntheticProvider(
53
80
  ): void {
54
81
  pi.registerProvider("synthetic", {
55
82
  baseUrl: "https://api.synthetic.new/openai/v1",
56
- apiKey: "SYNTHETIC_API_KEY",
83
+ apiKey: "$SYNTHETIC_API_KEY",
57
84
  api: "openai-completions",
58
85
  headers: {
59
86
  Referer: "https://pi.dev",
@@ -9,14 +9,68 @@ export interface SyntheticModelConfig extends ProviderModelConfig {
9
9
  provider: string;
10
10
  }
11
11
 
12
- export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
12
+ /** A thin alias that resolves to a concrete model at build time. */
13
+ export interface SyntheticModelAliasConfig {
14
+ id: string;
15
+ name: string;
16
+ /** Full model ID of the concrete target this alias resolves to. */
17
+ aliasFor: string;
18
+ }
19
+
20
+ /** Concrete model with full spec; aliases are excluded. */
21
+ export type ConcreteSyntheticModelConfig = SyntheticModelConfig & {
22
+ aliasFor?: never;
23
+ };
24
+
25
+ export function isAlias(
26
+ entry: ConcreteSyntheticModelConfig | SyntheticModelAliasConfig,
27
+ ): entry is SyntheticModelAliasConfig {
28
+ return "aliasFor" in entry;
29
+ }
30
+
31
+ export type SyntheticModelEntry =
32
+ | ConcreteSyntheticModelConfig
33
+ | SyntheticModelAliasConfig;
34
+
35
+ export const SYNTHETIC_MODELS: SyntheticModelEntry[] = [
36
+ // API: syn:large:text → alias for hf:zai-org/GLM-5.1
37
+ {
38
+ id: "syn:large:text",
39
+ name: "syn:large:text",
40
+ aliasFor: "hf:zai-org/GLM-5.1",
41
+ },
42
+ // API: syn:small:text → alias for hf:zai-org/GLM-4.7-Flash
43
+ {
44
+ id: "syn:small:text",
45
+ name: "syn:small:text",
46
+ aliasFor: "hf:zai-org/GLM-4.7-Flash",
47
+ },
48
+ // API: syn:large:vision → alias for hf:moonshotai/Kimi-K2.6
49
+ {
50
+ id: "syn:large:vision",
51
+ name: "syn:large:vision",
52
+ aliasFor: "hf:moonshotai/Kimi-K2.6",
53
+ },
54
+ // API: syn:small:vision → alias for hf:Qwen/Qwen3.6-27B
55
+ {
56
+ id: "syn:small:vision",
57
+ name: "syn:small:vision",
58
+ aliasFor: "hf:Qwen/Qwen3.6-27B",
59
+ },
13
60
  // API: hf:zai-org/GLM-4.7 → ctx=202752
14
61
  {
15
62
  id: "hf:zai-org/GLM-4.7",
16
63
  name: "zai-org/GLM-4.7",
17
64
  provider: "synthetic",
18
65
  reasoning: true,
19
- thinkingLevelMap: { minimal: null, xhigh: null },
66
+ thinkingLevelMap: {
67
+ off: "none",
68
+ minimal: null,
69
+ low: null,
70
+ medium: "medium",
71
+ high: null,
72
+ xhigh: null,
73
+ },
20
74
  compat: {
21
75
  supportsReasoningEffort: true,
22
76
  },
@@ -30,33 +84,20 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
30
84
  contextWindow: 202752,
31
85
  maxTokens: 65536,
32
86
  },
33
- // API: hf:zai-org/GLM-5 → ctx=196608, out=65536
34
- {
35
- id: "hf:zai-org/GLM-5",
36
- name: "zai-org/GLM-5",
37
- provider: "synthetic",
38
- reasoning: true,
39
- thinkingLevelMap: { minimal: null, xhigh: null },
40
- compat: {
41
- supportsReasoningEffort: true,
42
- },
43
- input: ["text"],
44
- cost: {
45
- input: 1,
46
- output: 3,
47
- cacheRead: 1,
48
- cacheWrite: 0,
49
- },
50
- contextWindow: 196608,
51
- maxTokens: 65536,
52
- },
53
87
  // API: hf:zai-org/GLM-5.1 → ctx=196608, out=65536
54
88
  {
55
89
  id: "hf:zai-org/GLM-5.1",
56
90
  name: "zai-org/GLM-5.1",
57
91
  provider: "synthetic",
58
92
  reasoning: true,
59
- thinkingLevelMap: { minimal: null, xhigh: null },
93
+ thinkingLevelMap: {
94
+ off: "none",
95
+ minimal: null,
96
+ low: null,
97
+ medium: "medium",
98
+ high: null,
99
+ xhigh: null,
100
+ },
60
101
  compat: {
61
102
  supportsReasoningEffort: true,
62
103
  supportsDeveloperRole: false,
@@ -77,7 +118,14 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
77
118
  name: "zai-org/GLM-4.7-Flash",
78
119
  provider: "synthetic",
79
120
  reasoning: true,
80
- thinkingLevelMap: { minimal: null, xhigh: null },
121
+ thinkingLevelMap: {
122
+ off: "none",
123
+ minimal: null,
124
+ low: null,
125
+ medium: "medium",
126
+ high: null,
127
+ xhigh: null,
128
+ },
81
129
  compat: {
82
130
  supportsReasoningEffort: true,
83
131
  },
@@ -91,22 +139,6 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
91
139
  contextWindow: 196608,
92
140
  maxTokens: 65536,
93
141
  },
94
- // models.dev: synthetic/hf:deepseek-ai/DeepSeek-V3.2 → ctx=162816, out=8000
95
- {
96
- id: "hf:deepseek-ai/DeepSeek-V3.2",
97
- name: "deepseek-ai/DeepSeek-V3.2",
98
- provider: "fireworks",
99
- reasoning: true,
100
- input: ["text"],
101
- cost: {
102
- input: 0.56,
103
- output: 1.68,
104
- cacheRead: 0.56,
105
- cacheWrite: 0,
106
- },
107
- contextWindow: 162816,
108
- maxTokens: 8000,
109
- },
110
142
  // models.dev: synthetic/hf:openai/gpt-oss-120b → ctx=128000, out=32768
111
143
  {
112
144
  id: "hf:openai/gpt-oss-120b",
@@ -145,7 +177,14 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
145
177
  name: "moonshotai/Kimi-K2.6",
146
178
  provider: "synthetic",
147
179
  reasoning: true,
148
- thinkingLevelMap: { minimal: null, low: null, xhigh: null },
180
+ thinkingLevelMap: {
181
+ off: "none",
182
+ minimal: null,
183
+ low: null,
184
+ medium: "medium",
185
+ high: null,
186
+ xhigh: null,
187
+ },
149
188
  compat: {
150
189
  supportsReasoningEffort: true,
151
190
  },
@@ -165,6 +204,17 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
165
204
  name: "Qwen/Qwen3.5-397B-A17B",
166
205
  provider: "together",
167
206
  reasoning: true,
207
+ thinkingLevelMap: {
208
+ off: "none",
209
+ minimal: null,
210
+ low: null,
211
+ medium: "medium",
212
+ high: null,
213
+ xhigh: null,
214
+ },
215
+ compat: {
216
+ supportsReasoningEffort: true,
217
+ },
168
218
  input: ["text", "image"],
169
219
  cost: {
170
220
  input: 0.6,
@@ -175,123 +225,83 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
175
225
  contextWindow: 262144,
176
226
  maxTokens: 65536,
177
227
  },
178
- // API: hf:MiniMaxAI/MiniMax-M2.5 → ctx=191488, out=65536
228
+ // API: hf:Qwen/Qwen3.6-27B → ctx=262144, out=65536
179
229
  {
180
- id: "hf:MiniMaxAI/MiniMax-M2.5",
181
- name: "MiniMaxAI/MiniMax-M2.5",
230
+ id: "hf:Qwen/Qwen3.6-27B",
231
+ name: "Qwen/Qwen3.6-27B",
182
232
  provider: "synthetic",
183
233
  reasoning: true,
184
- thinkingLevelMap: { off: null, minimal: null, low: null, xhigh: null },
185
- input: ["text"],
186
- cost: {
187
- input: 0.4,
188
- output: 2,
189
- cacheRead: 0.4,
190
- cacheWrite: 0,
191
- },
192
- contextWindow: 191488,
193
- maxTokens: 65536,
194
- compat: {
195
- supportsReasoningEffort: true,
196
- maxTokensField: "max_completion_tokens",
234
+ thinkingLevelMap: {
235
+ off: "none",
236
+ minimal: null,
237
+ low: null,
238
+ medium: "medium",
239
+ high: null,
240
+ xhigh: null,
197
241
  },
198
- },
199
- // API: hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4 → ctx=262144, out=65536
200
- {
201
- id: "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4",
202
- name: "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4",
203
- provider: "synthetic",
204
- reasoning: true,
205
- thinkingLevelMap: { minimal: null, low: null, xhigh: null },
206
242
  compat: {
207
243
  supportsReasoningEffort: true,
208
244
  },
209
- input: ["text"],
245
+ input: ["text", "image"],
210
246
  cost: {
211
- input: 0.3,
212
- output: 1,
213
- cacheRead: 0.3,
247
+ input: 0.45,
248
+ output: 3.6,
249
+ cacheRead: 0.45,
214
250
  cacheWrite: 0,
215
251
  },
216
252
  contextWindow: 262144,
217
253
  maxTokens: 65536,
218
254
  },
219
- // API: syn:large:text → alias for hf:zai-org/GLM-5.1 → ctx=196608, out=65536
255
+ // API: hf:MiniMaxAI/MiniMax-M2.5 → ctx=191488, out=65536
220
256
  {
221
- id: "syn:large:text",
222
- name: "syn:large:text",
257
+ id: "hf:MiniMaxAI/MiniMax-M2.5",
258
+ name: "MiniMaxAI/MiniMax-M2.5",
223
259
  provider: "synthetic",
224
260
  reasoning: true,
225
- thinkingLevelMap: { minimal: null, xhigh: null },
226
- compat: {
227
- supportsReasoningEffort: true,
228
- supportsDeveloperRole: false,
261
+ thinkingLevelMap: {
262
+ off: null,
263
+ minimal: null,
264
+ low: null,
265
+ medium: "medium",
266
+ high: null,
267
+ xhigh: null,
229
268
  },
230
269
  input: ["text"],
231
270
  cost: {
232
- input: 1,
233
- output: 3,
234
- cacheRead: 1,
271
+ input: 0.4,
272
+ output: 2,
273
+ cacheRead: 0.4,
235
274
  cacheWrite: 0,
236
275
  },
237
- contextWindow: 196608,
276
+ contextWindow: 191488,
238
277
  maxTokens: 65536,
239
- },
240
- // API: syn:small:text → alias for hf:zai-org/GLM-4.7-Flash → ctx=196608, out=65536
241
- {
242
- id: "syn:small:text",
243
- name: "syn:small:text",
244
- provider: "synthetic",
245
- reasoning: true,
246
- thinkingLevelMap: { minimal: null, xhigh: null },
247
278
  compat: {
248
279
  supportsReasoningEffort: true,
280
+ maxTokensField: "max_completion_tokens",
249
281
  },
250
- input: ["text"],
251
- cost: {
252
- input: 0.1,
253
- output: 0.5,
254
- cacheRead: 0.1,
255
- cacheWrite: 0,
256
- },
257
- contextWindow: 196608,
258
- maxTokens: 65536,
259
282
  },
260
- // API: syn:large:vision → alias for hf:moonshotai/Kimi-K2.6 → ctx=262144, out=65536
283
+ // API: hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4 → ctx=262144, out=65536
261
284
  {
262
- id: "syn:large:vision",
263
- name: "syn:large:vision",
285
+ id: "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4",
286
+ name: "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4",
264
287
  provider: "synthetic",
265
288
  reasoning: true,
266
- thinkingLevelMap: { minimal: null, low: null, xhigh: null },
267
- compat: {
268
- supportsReasoningEffort: true,
269
- },
270
- input: ["text", "image"],
271
- cost: {
272
- input: 0.95,
273
- output: 4,
274
- cacheRead: 0.95,
275
- cacheWrite: 0,
289
+ thinkingLevelMap: {
290
+ off: "none",
291
+ minimal: null,
292
+ low: null,
293
+ medium: "medium",
294
+ high: null,
295
+ xhigh: null,
276
296
  },
277
- contextWindow: 262144,
278
- maxTokens: 65536,
279
- },
280
- // API: syn:small:vision → alias for hf:moonshotai/Kimi-K2.6 → ctx=262144, out=65536
281
- {
282
- id: "syn:small:vision",
283
- name: "syn:small:vision",
284
- provider: "synthetic",
285
- reasoning: true,
286
- thinkingLevelMap: { minimal: null, low: null, xhigh: null },
287
297
  compat: {
288
298
  supportsReasoningEffort: true,
289
299
  },
290
- input: ["text", "image"],
300
+ input: ["text"],
291
301
  cost: {
292
- input: 0.95,
293
- output: 4,
294
- cacheRead: 0.95,
302
+ input: 0.3,
303
+ output: 1,
304
+ cacheRead: 0.3,
295
305
  cacheWrite: 0,
296
306
  },
297
307
  contextWindow: 262144,
@@ -3,16 +3,11 @@ import { writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { ToolCallHeader, ToolFooter } from "@aliou/pi-utils-ui";
6
- import type {
7
- AgentToolResult,
8
- ExtensionAPI,
9
- ExtensionContext,
10
- Theme,
11
- ToolRenderResultOptions,
12
- } from "@earendil-works/pi-coding-agent";
6
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
13
7
  import {
14
8
  DEFAULT_MAX_BYTES,
15
9
  DEFAULT_MAX_LINES,
10
+ defineTool,
16
11
  formatSize,
17
12
  keyHint,
18
13
  truncateHead,
@@ -60,261 +55,248 @@ const SearchParams = Type.Object({
60
55
 
61
56
  type SearchParamsType = Static<typeof SearchParams>;
62
57
 
63
- export function registerSyntheticWebSearchTool(pi: ExtensionAPI): void {
64
- pi.registerTool<typeof SearchParams, WebSearchDetails>({
65
- name: SYNTHETIC_WEB_SEARCH_TOOL,
66
- label: "Synthetic: Web Search",
67
- description: `Search the web using Synthetic's zero-data-retention API. Returns search results with titles, URLs, content snippets, and publication dates. Use for finding documentation, articles, recent information, or any web content. Results are fresh and not cached by Synthetic. Results are truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} (whichever is hit first). If truncated, full output is saved to a temp file.`,
68
- promptSnippet: "Search the web using Synthetic's zero-data-retention API",
69
- promptGuidelines: [
70
- "Use synthetic_web_search for finding documentation, articles, recent information, or any web content.",
71
- "Write specific queries with names, dates, versions, or locations for synthetic_web_search.",
72
- "synthetic_web_search results are fresh and not cached by Synthetic.",
73
- ],
74
- parameters: SearchParams,
75
-
76
- async execute(
77
- _toolCallId: string,
78
- params: SearchParamsType,
79
- signal: AbortSignal | undefined,
80
- onUpdate:
81
- | ((result: AgentToolResult<WebSearchDetails>) => void)
82
- | undefined,
83
- ctx: ExtensionContext,
84
- ): Promise<AgentToolResult<WebSearchDetails>> {
85
- onUpdate?.({
86
- content: [{ type: "text", text: "Searching..." }],
87
- details: { query: params.query },
58
+ export const syntheticWebSearchTool = defineTool({
59
+ name: SYNTHETIC_WEB_SEARCH_TOOL,
60
+ label: "Synthetic: Web Search",
61
+ description: `Search the web using Synthetic's zero-data-retention API. Returns search results with titles, URLs, content snippets, and publication dates. Use for finding documentation, articles, recent information, or any web content. Results are fresh and not cached by Synthetic. Results are truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} (whichever is hit first). If truncated, full output is saved to a temp file.`,
62
+ promptSnippet: "Search the web using Synthetic's zero-data-retention API",
63
+ promptGuidelines: [
64
+ "Use synthetic_web_search for finding documentation, articles, recent information, or any web content.",
65
+ "Write specific queries with names, dates, versions, or locations for synthetic_web_search.",
66
+ "synthetic_web_search results are fresh and not cached by Synthetic.",
67
+ ],
68
+ parameters: SearchParams,
69
+
70
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
71
+ onUpdate?.({
72
+ content: [{ type: "text", text: "Searching..." }],
73
+ details: { query: params.query },
74
+ });
75
+
76
+ if (!configLoader.getConfig().webSearch) {
77
+ throw new Error(
78
+ "Synthetic web search is disabled. Re-enable it with synthetic:settings or pi config.",
79
+ );
80
+ }
81
+
82
+ const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
83
+ if (!apiKey) {
84
+ throw new Error(
85
+ "Synthetic web search requires a Synthetic subscription. Add credentials to ~/.pi/agent/auth.json or set SYNTHETIC_API_KEY environment variable.",
86
+ );
87
+ }
88
+
89
+ const response = await fetch("https://api.synthetic.new/v2/search", {
90
+ method: "POST",
91
+ headers: {
92
+ Authorization: `Bearer ${apiKey}`,
93
+ "Content-Type": "application/json",
94
+ },
95
+ body: JSON.stringify({ query: params.query }),
96
+ signal,
97
+ });
98
+
99
+ if (!response.ok) {
100
+ const errorText = await response.text();
101
+ throw new Error(`Search API error: ${response.status} ${errorText}`);
102
+ }
103
+
104
+ let data: SyntheticSearchResponse;
105
+ try {
106
+ data = await response.json();
107
+ } catch (parseError) {
108
+ throw new Error(
109
+ parseError instanceof Error
110
+ ? `Failed to parse search results: ${parseError.message}`
111
+ : "Failed to parse search results",
112
+ );
113
+ }
114
+
115
+ let content = `Found ${data.results.length} result(s):\n\n`;
116
+ const resultDetails: WebSearchResultDetails[] = [];
117
+
118
+ for (let i = 0; i < data.results.length; i++) {
119
+ const result = data.results[i];
120
+ const slug = result.title
121
+ .toLowerCase()
122
+ .replace(/[^a-z0-9]+/g, "-")
123
+ .replace(/(^-|-$)/g, "")
124
+ .slice(0, 40);
125
+ const truncation = truncateHead(result.text, {
126
+ maxLines: DEFAULT_MAX_LINES,
127
+ maxBytes: DEFAULT_MAX_BYTES,
88
128
  });
89
129
 
90
- if (!configLoader.getConfig().webSearch) {
91
- throw new Error(
92
- "Synthetic web search is disabled. Re-enable it with synthetic:settings or pi config.",
93
- );
94
- }
130
+ let preview = truncation.content;
131
+ let tempFilePath: string | undefined;
95
132
 
96
- const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
97
- if (!apiKey) {
98
- throw new Error(
99
- "Synthetic web search requires a Synthetic subscription. Add credentials to ~/.pi/agent/auth.json or set SYNTHETIC_API_KEY environment variable.",
133
+ if (truncation.truncated) {
134
+ tempFilePath = join(
135
+ tmpdir(),
136
+ `pi-synthetic-search-${slug}-${randomBytes(4).toString("hex")}.md`,
100
137
  );
138
+ await writeFile(tempFilePath, result.text, "utf8");
139
+ preview += `\n\n[Result truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full result: ${tempFilePath}]`;
101
140
  }
102
141
 
103
- const response = await fetch("https://api.synthetic.new/v2/search", {
104
- method: "POST",
105
- headers: {
106
- Authorization: `Bearer ${apiKey}`,
107
- "Content-Type": "application/json",
108
- },
109
- body: JSON.stringify({ query: params.query }),
110
- signal,
142
+ content += `## ${result.title}\n`;
143
+ content += `URL: ${result.url}\n`;
144
+ content += `Published: ${result.published}\n`;
145
+ content += `\n${preview}\n`;
146
+ content += "\n---\n\n";
147
+
148
+ resultDetails.push({
149
+ title: result.title,
150
+ url: result.url,
151
+ published: result.published,
152
+ truncated: truncation.truncated,
153
+ tempFilePath,
154
+ totalLines: truncation.totalLines,
155
+ totalBytes: truncation.totalBytes,
156
+ outputLines: truncation.outputLines,
157
+ outputBytes: truncation.outputBytes,
111
158
  });
159
+ }
160
+
161
+ return {
162
+ content: [{ type: "text", text: content }],
163
+ details: {
164
+ results: resultDetails,
165
+ query: params.query,
166
+ },
167
+ };
168
+ },
169
+
170
+ renderCall(args: SearchParamsType, theme: Theme) {
171
+ return new ToolCallHeader(
172
+ {
173
+ toolName: "Synthetic: WebSearch",
174
+ mainArg: `"${args.query}"`,
175
+ showColon: true,
176
+ },
177
+ theme,
178
+ );
179
+ },
180
+
181
+ renderResult(result, options, theme: Theme) {
182
+ const { expanded, isPartial } = options;
183
+
184
+ if (isPartial) {
185
+ return new Text(
186
+ theme.fg("muted", "Synthetic: WebSearch: fetching..."),
187
+ 0,
188
+ 0,
189
+ );
190
+ }
191
+
192
+ const details = result.details as WebSearchDetails | undefined;
193
+ const results = details?.results || [];
194
+ const container = new Container();
195
+
196
+ // When the tool throws, the framework calls renderResult with
197
+ // details={} (empty object) and the error message in content.
198
+ // Detect this by checking for missing results in details.
199
+ if (!details?.results) {
200
+ const textBlock = result.content.find((c) => c.type === "text");
201
+ const errorMsg =
202
+ (textBlock?.type === "text" && textBlock.text) || "Search failed";
203
+ container.addChild(new Text(theme.fg("error", errorMsg), 0, 0));
204
+ return container;
205
+ }
112
206
 
113
- if (!response.ok) {
114
- const errorText = await response.text();
115
- throw new Error(`Search API error: ${response.status} ${errorText}`);
116
- }
207
+ const hasTruncation = results.some((r) => r.truncated);
117
208
 
118
- let data: SyntheticSearchResponse;
119
- try {
120
- data = await response.json();
121
- } catch (parseError) {
122
- throw new Error(
123
- parseError instanceof Error
124
- ? `Failed to parse search results: ${parseError.message}`
125
- : "Failed to parse search results",
126
- );
209
+ if (results.length === 0) {
210
+ container.addChild(
211
+ new Text(theme.fg("muted", "Synthetic: WebSearch: no results"), 0, 0),
212
+ );
213
+ } else if (!expanded) {
214
+ // Collapsed: show result count + first result title
215
+ let text = theme.fg("success", `Found ${results.length} result(s)`);
216
+ if (hasTruncation) {
217
+ text += theme.fg("warning", " (truncated)");
127
218
  }
128
-
129
- let content = `Found ${data.results.length} result(s):\n\n`;
130
- const resultDetails: WebSearchResultDetails[] = [];
131
-
132
- for (let i = 0; i < data.results.length; i++) {
133
- const result = data.results[i];
134
- const slug = result.title
135
- .toLowerCase()
136
- .replace(/[^a-z0-9]+/g, "-")
137
- .replace(/(^-|-$)/g, "")
138
- .slice(0, 40);
139
- const truncation = truncateHead(result.text, {
140
- maxLines: DEFAULT_MAX_LINES,
141
- maxBytes: DEFAULT_MAX_BYTES,
142
- });
143
-
144
- let preview = truncation.content;
145
- let tempFilePath: string | undefined;
146
-
147
- if (truncation.truncated) {
148
- tempFilePath = join(
149
- tmpdir(),
150
- `pi-synthetic-search-${slug}-${randomBytes(4).toString("hex")}.md`,
151
- );
152
- await writeFile(tempFilePath, result.text, "utf8");
153
- preview += `\n\n[Result truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full result: ${tempFilePath}]`;
219
+ const first = results[0];
220
+ if (first) {
221
+ text += `\n ${theme.fg("dim", first.title)}`;
222
+ if (results.length > 1) {
223
+ text += theme.fg("dim", ` (+${results.length - 1} more)`);
154
224
  }
155
-
156
- content += `## ${result.title}\n`;
157
- content += `URL: ${result.url}\n`;
158
- content += `Published: ${result.published}\n`;
159
- content += `\n${preview}\n`;
160
- content += "\n---\n\n";
161
-
162
- resultDetails.push({
163
- title: result.title,
164
- url: result.url,
165
- published: result.published,
166
- truncated: truncation.truncated,
167
- tempFilePath,
168
- totalLines: truncation.totalLines,
169
- totalBytes: truncation.totalBytes,
170
- outputLines: truncation.outputLines,
171
- outputBytes: truncation.outputBytes,
172
- });
173
225
  }
174
-
175
- return {
176
- content: [{ type: "text", text: content }],
177
- details: {
178
- results: resultDetails,
179
- query: params.query,
180
- },
181
- };
182
- },
183
-
184
- renderCall(args: SearchParamsType, theme: Theme) {
185
- return new ToolCallHeader(
186
- {
187
- toolName: "Synthetic: WebSearch",
188
- mainArg: `"${args.query}"`,
189
- showColon: true,
190
- },
191
- theme,
192
- );
193
- },
194
-
195
- renderResult(
196
- result: AgentToolResult<WebSearchDetails>,
197
- options: ToolRenderResultOptions,
198
- theme: Theme,
199
- ) {
200
- const { expanded, isPartial } = options;
201
-
202
- if (isPartial) {
203
- return new Text(
204
- theme.fg("muted", "Synthetic: WebSearch: fetching..."),
226
+ text += theme.fg("muted", ` ${keyHint("app.tools.expand", "to expand")}`);
227
+ container.addChild(new Text(text, 0, 0));
228
+ } else {
229
+ // Expanded: show each result with title, URL, date, and snippet
230
+ container.addChild(
231
+ new Text(
232
+ theme.fg("success", `Found ${results.length} result(s)`),
205
233
  0,
206
234
  0,
207
- );
208
- }
209
-
210
- const details = result.details;
211
- const results = details?.results || [];
212
- const container = new Container();
213
-
214
- // When the tool throws, the framework calls renderResult with
215
- // details={} (empty object) and the error message in content.
216
- // Detect this by checking for missing results in details.
217
- if (!details?.results) {
218
- const textBlock = result.content.find((c) => c.type === "text");
219
- const errorMsg =
220
- (textBlock?.type === "text" && textBlock.text) || "Search failed";
221
- container.addChild(new Text(theme.fg("error", errorMsg), 0, 0));
222
- return container;
223
- }
224
-
225
- const hasTruncation = results.some((r) => r.truncated);
235
+ ),
236
+ );
226
237
 
227
- if (results.length === 0) {
228
- container.addChild(
229
- new Text(theme.fg("muted", "Synthetic: WebSearch: no results"), 0, 0),
230
- );
231
- } else if (!expanded) {
232
- // Collapsed: show result count + first result title
233
- let text = theme.fg("success", `Found ${results.length} result(s)`);
234
- if (hasTruncation) {
235
- text += theme.fg("warning", " (truncated)");
236
- }
237
- const first = results[0];
238
- if (first) {
239
- text += `\n ${theme.fg("dim", first.title)}`;
240
- if (results.length > 1) {
241
- text += theme.fg("dim", ` (+${results.length - 1} more)`);
242
- }
243
- }
244
- text += theme.fg(
245
- "muted",
246
- ` ${keyHint("app.tools.expand", "to expand")}`,
247
- );
248
- container.addChild(new Text(text, 0, 0));
249
- } else {
250
- // Expanded: show each result with title, URL, date, and snippet
238
+ for (const r of results) {
239
+ container.addChild(new Text("", 0, 0));
251
240
  container.addChild(
252
241
  new Text(
253
- theme.fg("success", `Found ${results.length} result(s)`),
242
+ `${theme.fg("dim", ">")} ${theme.fg("accent", theme.bold(r.title))}`,
254
243
  0,
255
244
  0,
256
245
  ),
257
246
  );
247
+ container.addChild(new Text(` ${theme.fg("dim", r.url)}`, 0, 0));
248
+ if (r.published) {
249
+ container.addChild(
250
+ new Text(
251
+ ` ${theme.fg("muted", `Published: ${r.published}`)}`,
252
+ 0,
253
+ 0,
254
+ ),
255
+ );
256
+ }
258
257
 
259
- for (const r of results) {
260
- container.addChild(new Text("", 0, 0));
258
+ if (r.truncated) {
261
259
  container.addChild(
262
260
  new Text(
263
- `${theme.fg("dim", ">")} ${theme.fg("accent", theme.bold(r.title))}`,
261
+ ` ${theme.fg("warning", `Truncated: ${r.outputLines} of ${r.totalLines} lines (${formatSize(r.outputBytes)} of ${formatSize(r.totalBytes)}). Full content: ${r.tempFilePath}`)}`,
264
262
  0,
265
263
  0,
266
264
  ),
267
265
  );
268
- container.addChild(new Text(` ${theme.fg("dim", r.url)}`, 0, 0));
269
- if (r.published) {
270
- container.addChild(
271
- new Text(
272
- ` ${theme.fg("muted", `Published: ${r.published}`)}`,
273
- 0,
274
- 0,
275
- ),
276
- );
277
- }
278
-
279
- if (r.truncated) {
280
- container.addChild(
281
- new Text(
282
- ` ${theme.fg("warning", `Truncated: ${r.outputLines} of ${r.totalLines} lines (${formatSize(r.outputBytes)} of ${formatSize(r.totalBytes)}). Full content: ${r.tempFilePath}`)}`,
283
- 0,
284
- 0,
285
- ),
286
- );
287
- }
288
266
  }
289
267
  }
290
-
291
- const footerItems: { label: string; value: string }[] = [];
268
+ }
269
+
270
+ const footerItems: { label: string; value: string }[] = [];
271
+ footerItems.push({
272
+ label: "results",
273
+ value: `${results.length} result(s)`,
274
+ });
275
+ if (hasTruncation) {
276
+ const truncatedCount = results.filter((r) => r.truncated).length;
292
277
  footerItems.push({
293
- label: "results",
294
- value: `${results.length} result(s)`,
278
+ label: "truncated",
279
+ value: `${truncatedCount}`,
295
280
  });
296
- if (hasTruncation) {
297
- const truncatedCount = results.filter((r) => r.truncated).length;
298
- footerItems.push({
299
- label: "truncated",
300
- value: `${truncatedCount}`,
301
- });
302
- }
303
- if (!expanded) {
304
- footerItems.push({
305
- label: "",
306
- value: keyHint("app.tools.expand", "to expand"),
307
- });
308
- }
309
- container.addChild(new Text("", 0, 0));
310
- container.addChild(
311
- new ToolFooter(theme, {
312
- items: footerItems,
313
- separator: " | ",
314
- }),
315
- );
281
+ }
282
+ if (!expanded) {
283
+ footerItems.push({
284
+ label: "",
285
+ value: keyHint("app.tools.expand", "to expand"),
286
+ });
287
+ }
288
+ container.addChild(new Text("", 0, 0));
289
+ container.addChild(
290
+ new ToolFooter(theme, {
291
+ items: footerItems,
292
+ separator: " | ",
293
+ }),
294
+ );
295
+
296
+ return container;
297
+ },
298
+ });
316
299
 
317
- return container;
318
- },
319
- });
300
+ export function registerSyntheticWebSearchTool(pi: ExtensionAPI): void {
301
+ pi.registerTool(syntheticWebSearchTool);
320
302
  }