pi-web-providers 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +52 -4
  2. package/dist/index.js +536 -54
  3. package/package.json +5 -3
package/README.md CHANGED
@@ -28,7 +28,8 @@ tool prompt aligned with the tools that the agent can actually call.
28
28
 
29
29
  - **Provider-driven tool surface** — tools are injected based on what the active
30
30
  provider actually supports, not a fixed list
31
- - **Six providers**: Claude, Codex, Exa, Gemini, Parallel, Valyu — each with
31
+ - **Multiple providers**: Claude, Codex, Exa, Gemini, Perplexity, Parallel,
32
+ Valyu — each with
32
33
  its own SDK, strengths, and capability set
33
34
  - **One config command** (`/web-providers`) with a TUI that adapts to the
34
35
  selected provider
@@ -75,7 +76,8 @@ Find likely sources on the public web and return titles, URLs, and snippets.
75
76
  | ------------ | ------- | -------- | ----------------------------------------------------------------------------- |
76
77
  | `query` | string | required | What to search for |
77
78
  | `maxResults` | integer | `5` | Result count, clamped to `1–20` |
78
- | `provider` | string | auto | Optional override: `claude`, `codex`, `exa`, `gemini`, `parallel`, or `valyu` |
79
+ | `options` | object | | Provider-specific search options |
80
+ | `provider` | string | auto | Optional override: `claude`, `codex`, `exa`, `gemini`, `perplexity`, `parallel`, or `valyu` |
79
81
 
80
82
  ### `web_contents`
81
83
 
@@ -107,6 +109,11 @@ Investigate a topic across web sources and produce a longer report.
107
109
  | `options` | object | — | Provider-specific research options |
108
110
  | `provider` | string | auto | Optional override among providers that support research |
109
111
 
112
+ `options` are provider-native and provider-specific. Equivalent concepts can
113
+ use different field names across SDKs, for example Perplexity uses `country`,
114
+ Exa uses `userLocation`, and Valyu uses `countryCode`. Runtime `options`
115
+ override provider defaults, but managed tool inputs and tool wiring stay fixed.
116
+
110
117
  ## 🔌 Providers
111
118
 
112
119
  Every provider is a thin adapter around an official SDK. The table below
@@ -117,7 +124,8 @@ summarises which capabilities each provider exposes:
117
124
  | **Claude** | ✓ | | ✓ | | Local Claude Code auth |
118
125
  | **Codex** | ✓ | | | | Local Codex CLI auth |
119
126
  | **Exa** | ✓ | ✓ | ✓ | ✓ | `EXA_API_KEY` |
120
- | **Gemini** | ✓ | | ✓ | ✓ | `GOOGLE_API_KEY` |
127
+ | **Gemini** | ✓ || ✓ | ✓ | `GOOGLE_API_KEY` |
128
+ | **Perplexity** | ✓ | | ✓ | ✓ | `PERPLEXITY_API_KEY` |
121
129
  | **Parallel** | ✓ | ✓ | | | `PARALLEL_API_KEY` |
122
130
  | **Valyu** | ✓ | ✓ | ✓ | ✓ | `VALYU_API_KEY` |
123
131
 
@@ -126,12 +134,16 @@ summarises which capabilities each provider exposes:
126
134
  - SDK: `@anthropic-ai/claude-agent-sdk`
127
135
  - Uses Claude Code's built-in `WebSearch` and `WebFetch` tools behind a
128
136
  structured JSON adapter
137
+ - Supports request-shaping `options` such as `model`, `thinking`, `effort`, and
138
+ `maxTurns`
129
139
  - Great for search plus grounded answers if you already use Claude Code locally
130
140
 
131
141
  ### Codex
132
142
 
133
143
  - SDK: `@openai/codex-sdk`
134
144
  - Runs in read-only mode with web search enabled
145
+ - Supports request-shaping `web_search.options` such as `model`,
146
+ `modelReasoningEffort`, and `webSearchMode`
135
147
  - Best if you already use the local Codex CLI and auth flow
136
148
 
137
149
  ### Exa
@@ -143,18 +155,32 @@ summarises which capabilities each provider exposes:
143
155
  ### Gemini
144
156
 
145
157
  - SDK: `@google/genai`
146
- - Grounded answers and deep-research agents via Google's Gemini API
158
+ - Google Search grounding for answers and URL Context extraction for page contents
159
+ - Deep-research agents via Google's Gemini API
160
+ - Supports provider-native request options such as `model`, `config`,
161
+ `generation_config`, and `agent_config` depending on the tool
162
+
163
+ ### Perplexity
164
+
165
+ - SDK: `@perplexity-ai/perplexity_ai`
166
+ - Uses Perplexity Search for `web_search`
167
+ - Uses Sonar for `web_answer` and `sonar-deep-research` for `web_research`
168
+ - Supports provider-specific `web_search.options` such as `country`,
169
+ `search_mode`, `search_domain_filter`, and `search_recency_filter`
147
170
 
148
171
  ### Parallel
149
172
 
150
173
  - SDK: `parallel-web`
151
174
  - Agentic and one-shot search modes
152
175
  - Page content extraction with excerpt and full-content toggles
176
+ - Supports provider-native search and extraction options from the Parallel SDK
153
177
 
154
178
  ### Valyu
155
179
 
156
180
  - SDK: `valyu-js`
157
181
  - Web, proprietary, and news search types
182
+ - Supports provider-native options such as `countryCode`, `responseLength`, and
183
+ search/source filters
158
184
  - Configurable response length for answers and research
159
185
 
160
186
  ## 📝 Config Notes
@@ -220,16 +246,38 @@ Example:
220
246
  "enabled": false,
221
247
  "tools": {
222
248
  "search": true,
249
+ "contents": true,
223
250
  "answer": true,
224
251
  "research": true
225
252
  },
226
253
  "apiKey": "GOOGLE_API_KEY",
227
254
  "defaults": {
228
255
  "searchModel": "gemini-2.5-flash",
256
+ "contentsModel": "gemini-2.5-flash",
229
257
  "answerModel": "gemini-2.5-flash",
230
258
  "researchAgent": "deep-research-pro-preview-12-2025"
231
259
  }
232
260
  },
261
+ "perplexity": {
262
+ "enabled": false,
263
+ "tools": {
264
+ "search": true,
265
+ "answer": true,
266
+ "research": true
267
+ },
268
+ "apiKey": "PERPLEXITY_API_KEY",
269
+ "defaults": {
270
+ "search": {
271
+ "country": "US"
272
+ },
273
+ "answer": {
274
+ "model": "sonar"
275
+ },
276
+ "research": {
277
+ "model": "sonar-deep-research"
278
+ }
279
+ }
280
+ },
233
281
  "parallel": {
234
282
  "enabled": false,
235
283
  "tools": {
package/dist/index.js CHANGED
@@ -32,7 +32,8 @@ var PROVIDER_TOOLS = {
32
32
  claude: ["search", "answer"],
33
33
  codex: ["search"],
34
34
  exa: ["search", "contents", "answer", "research"],
35
- gemini: ["search", "answer", "research"],
35
+ gemini: ["search", "contents", "answer", "research"],
36
+ perplexity: ["search", "answer", "research"],
36
37
  parallel: ["search", "contents"],
37
38
  valyu: ["search", "contents", "answer", "research"]
38
39
  };
@@ -195,6 +196,12 @@ function normalizeConfig(raw, source) {
195
196
  source
196
197
  );
197
198
  }
199
+ if (raw.providers.perplexity !== void 0) {
200
+ config.providers.perplexity = normalizePerplexityProvider(
201
+ raw.providers.perplexity,
202
+ source
203
+ );
204
+ }
198
205
  if (raw.providers.parallel !== void 0) {
199
206
  config.providers.parallel = normalizeParallelProvider(
200
207
  raw.providers.parallel,
@@ -208,7 +215,7 @@ function normalizeConfig(raw, source) {
208
215
  );
209
216
  }
210
217
  const unknownProviders = Object.keys(raw.providers).filter(
211
- (key) => key !== "claude" && key !== "codex" && key !== "exa" && key !== "gemini" && key !== "parallel" && key !== "valyu"
218
+ (key) => key !== "claude" && key !== "codex" && key !== "exa" && key !== "gemini" && key !== "perplexity" && key !== "parallel" && key !== "valyu"
212
219
  );
213
220
  if (unknownProviders.length > 0) {
214
221
  throw new Error(
@@ -435,6 +442,11 @@ function normalizeGeminiProvider(raw, source) {
435
442
  source,
436
443
  "providers.gemini.defaults.searchModel"
437
444
  ),
445
+ contentsModel: parseOptionalString(
446
+ defaults.contentsModel,
447
+ source,
448
+ "providers.gemini.defaults.contentsModel"
449
+ ),
438
450
  answerModel: parseOptionalString(
439
451
  defaults.answerModel,
440
452
  source,
@@ -448,6 +460,54 @@ function normalizeGeminiProvider(raw, source) {
448
460
  }
449
461
  };
450
462
  }
463
+ function normalizePerplexityProvider(raw, source) {
464
+ const provider = parseProviderObject(raw, source, "perplexity");
465
+ const defaults = parseOptionalJsonObject(
466
+ provider.defaults,
467
+ source,
468
+ "providers.perplexity.defaults"
469
+ );
470
+ return {
471
+ enabled: parseOptionalBoolean(
472
+ provider.enabled,
473
+ source,
474
+ "providers.perplexity.enabled"
475
+ ),
476
+ tools: parseOptionalProviderTools(
477
+ "perplexity",
478
+ provider.tools,
479
+ source,
480
+ "providers.perplexity.tools"
481
+ ),
482
+ apiKey: parseOptionalString(
483
+ provider.apiKey,
484
+ source,
485
+ "providers.perplexity.apiKey"
486
+ ),
487
+ baseUrl: parseOptionalString(
488
+ provider.baseUrl,
489
+ source,
490
+ "providers.perplexity.baseUrl"
491
+ ),
492
+ defaults: defaults === void 0 ? void 0 : {
493
+ search: parseOptionalJsonObject(
494
+ defaults.search,
495
+ source,
496
+ "providers.perplexity.defaults.search"
497
+ ),
498
+ answer: parseOptionalJsonObject(
499
+ defaults.answer,
500
+ source,
501
+ "providers.perplexity.defaults.answer"
502
+ ),
503
+ research: parseOptionalJsonObject(
504
+ defaults.research,
505
+ source,
506
+ "providers.perplexity.defaults.research"
507
+ )
508
+ }
509
+ };
510
+ }
451
511
  function normalizeParallelProvider(raw, source) {
452
512
  const provider = parseProviderObject(raw, source, "parallel");
453
513
  const defaults = parseOptionalJsonObject(
@@ -698,7 +758,7 @@ var ClaudeProvider = class {
698
758
  }
699
759
  return { available: true, summary: "enabled" };
700
760
  }
701
- async search(queryText, maxResults, config, context) {
761
+ async search(queryText, maxResults, options, config, context) {
702
762
  const output = parseClaudeSearchOutput(
703
763
  await this.runStructuredQuery({
704
764
  prompt: [
@@ -715,7 +775,8 @@ var ClaudeProvider = class {
715
775
  schema: SEARCH_OUTPUT_SCHEMA,
716
776
  tools: ["WebSearch"],
717
777
  config,
718
- context
778
+ context,
779
+ options
719
780
  })
720
781
  );
721
782
  return {
@@ -738,13 +799,13 @@ var ClaudeProvider = class {
738
799
  "Keep the answer concise but informative.",
739
800
  "Only cite sources you actually used.",
740
801
  "",
741
- `User query: ${queryText}`,
742
- options ? `Additional options: ${JSON.stringify(options)}` : ""
743
- ].filter(Boolean).join("\n"),
802
+ `User query: ${queryText}`
803
+ ].join("\n"),
744
804
  schema: ANSWER_OUTPUT_SCHEMA,
745
805
  tools: ["WebSearch", "WebFetch"],
746
806
  config,
747
- context
807
+ context,
808
+ options
748
809
  })
749
810
  );
750
811
  const lines = [];
@@ -769,7 +830,8 @@ var ClaudeProvider = class {
769
830
  schema,
770
831
  tools,
771
832
  config,
772
- context
833
+ context,
834
+ options
773
835
  }) {
774
836
  const abortController = new AbortController();
775
837
  if (context.signal?.aborted) {
@@ -785,9 +847,7 @@ var ClaudeProvider = class {
785
847
  abortController,
786
848
  allowedTools: tools,
787
849
  cwd: context.cwd,
788
- effort: config.defaults?.effort,
789
- maxTurns: config.defaults?.maxTurns,
790
- model: config.defaults?.model,
850
+ ...getClaudeRuntimeOptions(config, options),
791
851
  outputFormat: {
792
852
  type: "json_schema",
793
853
  schema
@@ -939,6 +999,40 @@ function parseStructuredOutput(result) {
939
999
  return JSON.parse(match[0]);
940
1000
  }
941
1001
  }
1002
+ function getClaudeRuntimeOptions(config, options) {
1003
+ const model = readNonEmptyString(options?.model) ?? config.defaults?.model;
1004
+ const effort = readEnum(options?.effort, ["low", "medium", "high", "max"]);
1005
+ const maxTurns = readPositiveInteger(options?.maxTurns);
1006
+ const maxThinkingTokens = readNonNegativeInteger(options?.maxThinkingTokens);
1007
+ const maxBudgetUsd = readPositiveNumber(options?.maxBudgetUsd);
1008
+ const thinking = isPlainObject2(options?.thinking) ? options?.thinking : void 0;
1009
+ return {
1010
+ ...model ? { model } : {},
1011
+ ...effort ?? config.defaults?.effort ? { effort: effort ?? config.defaults?.effort } : {},
1012
+ ...maxTurns ?? config.defaults?.maxTurns ? { maxTurns: maxTurns ?? config.defaults?.maxTurns } : {},
1013
+ ...maxThinkingTokens !== void 0 ? { maxThinkingTokens } : {},
1014
+ ...maxBudgetUsd !== void 0 ? { maxBudgetUsd } : {},
1015
+ ...thinking ? { thinking } : {}
1016
+ };
1017
+ }
1018
+ function readNonEmptyString(value) {
1019
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
1020
+ }
1021
+ function readPositiveInteger(value) {
1022
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
1023
+ }
1024
+ function readNonNegativeInteger(value) {
1025
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
1026
+ }
1027
+ function readPositiveNumber(value) {
1028
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
1029
+ }
1030
+ function readEnum(value, values) {
1031
+ return typeof value === "string" && values.includes(value) ? value : void 0;
1032
+ }
1033
+ function isPlainObject2(value) {
1034
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1035
+ }
942
1036
  function parseClaudeSearchOutput(value) {
943
1037
  const sources = readArray(value, "sources").map((entry) => ({
944
1038
  title: readString(entry, "title"),
@@ -1042,7 +1136,7 @@ var CodexProvider = class {
1042
1136
  }
1043
1137
  return { available: true, summary: "enabled" };
1044
1138
  }
1045
- async search(query2, maxResults, config, context) {
1139
+ async search(query2, maxResults, options, config, context) {
1046
1140
  const codex = new Codex({
1047
1141
  codexPathOverride: config.codexPath,
1048
1142
  baseUrl: config.baseUrl,
@@ -1050,18 +1144,9 @@ var CodexProvider = class {
1050
1144
  config: config.config,
1051
1145
  env: resolveEnvMap(config.env)
1052
1146
  });
1053
- const thread = codex.startThread({
1054
- additionalDirectories: config.defaults?.additionalDirectories,
1055
- approvalPolicy: "never",
1056
- model: config.defaults?.model,
1057
- modelReasoningEffort: config.defaults?.modelReasoningEffort,
1058
- networkAccessEnabled: config.defaults?.networkAccessEnabled ?? true,
1059
- sandboxMode: "read-only",
1060
- skipGitRepoCheck: true,
1061
- webSearchEnabled: config.defaults?.webSearchEnabled ?? true,
1062
- webSearchMode: config.defaults?.webSearchMode ?? "live",
1063
- workingDirectory: context.cwd
1064
- });
1147
+ const thread = codex.startThread(
1148
+ buildCodexSearchThreadOptions(config, context.cwd, options)
1149
+ );
1065
1150
  const prompt = [
1066
1151
  "You are performing web research for another coding agent.",
1067
1152
  "Search the public web and return only a JSON object matching the provided schema.",
@@ -1098,6 +1183,50 @@ var CodexProvider = class {
1098
1183
  };
1099
1184
  }
1100
1185
  };
1186
+ function buildCodexSearchThreadOptions(config, cwd, options) {
1187
+ const runtimeOptions = getCodexSearchRuntimeOptions(options);
1188
+ return {
1189
+ additionalDirectories: config.defaults?.additionalDirectories,
1190
+ approvalPolicy: "never",
1191
+ model: runtimeOptions.model ?? config.defaults?.model,
1192
+ modelReasoningEffort: runtimeOptions.modelReasoningEffort ?? config.defaults?.modelReasoningEffort,
1193
+ networkAccessEnabled: config.defaults?.networkAccessEnabled ?? true,
1194
+ sandboxMode: "read-only",
1195
+ skipGitRepoCheck: true,
1196
+ webSearchEnabled: config.defaults?.webSearchEnabled ?? true,
1197
+ webSearchMode: runtimeOptions.webSearchMode ?? config.defaults?.webSearchMode ?? "live",
1198
+ workingDirectory: cwd
1199
+ };
1200
+ }
1201
+ function getCodexSearchRuntimeOptions(options) {
1202
+ if (!options) {
1203
+ return {};
1204
+ }
1205
+ const model = readNonEmptyString2(options.model);
1206
+ const modelReasoningEffort = readEnum2(options.modelReasoningEffort, [
1207
+ "minimal",
1208
+ "low",
1209
+ "medium",
1210
+ "high",
1211
+ "xhigh"
1212
+ ]);
1213
+ const webSearchMode = readEnum2(options.webSearchMode, [
1214
+ "disabled",
1215
+ "cached",
1216
+ "live"
1217
+ ]);
1218
+ return {
1219
+ ...model ? { model } : {},
1220
+ ...modelReasoningEffort ? { modelReasoningEffort } : {},
1221
+ ...webSearchMode ? { webSearchMode } : {}
1222
+ };
1223
+ }
1224
+ function readNonEmptyString2(value) {
1225
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
1226
+ }
1227
+ function readEnum2(value, values) {
1228
+ return typeof value === "string" && values.includes(value) ? value : void 0;
1229
+ }
1101
1230
  function hasCodexCredentials(config) {
1102
1231
  if (hasConfiguredReference(config.apiKey)) {
1103
1232
  return true;
@@ -1188,7 +1317,7 @@ var ExaProvider = class {
1188
1317
  }
1189
1318
  return { available: true, summary: "enabled" };
1190
1319
  }
1191
- async search(query2, maxResults, config, context) {
1320
+ async search(query2, maxResults, searchOptions, config, context) {
1192
1321
  const apiKey = resolveConfigValue(config.apiKey);
1193
1322
  if (!apiKey) {
1194
1323
  throw new Error("Exa is missing an API key.");
@@ -1196,6 +1325,7 @@ var ExaProvider = class {
1196
1325
  const client = new Exa(apiKey, config.baseUrl);
1197
1326
  const options = {
1198
1327
  ...asJsonObject(config.defaults),
1328
+ ...searchOptions ?? {},
1199
1329
  numResults: maxResults
1200
1330
  };
1201
1331
  context.onProgress?.(`Searching Exa for: ${query2}`);
@@ -1304,6 +1434,7 @@ var ExaProvider = class {
1304
1434
  // src/providers/gemini.ts
1305
1435
  import { GoogleGenAI } from "@google/genai";
1306
1436
  var DEFAULT_SEARCH_MODEL = "gemini-2.5-flash";
1437
+ var DEFAULT_CONTENTS_MODEL = "gemini-2.5-flash";
1307
1438
  var DEFAULT_ANSWER_MODEL = "gemini-2.5-flash";
1308
1439
  var DEFAULT_RESEARCH_AGENT = "deep-research-pro-preview-12-2025";
1309
1440
  var DEFAULT_POLL_INTERVAL_MS = 3e3;
@@ -1316,12 +1447,14 @@ var GeminiProvider = class {
1316
1447
  enabled: false,
1317
1448
  tools: {
1318
1449
  search: true,
1450
+ contents: true,
1319
1451
  answer: true,
1320
1452
  research: true
1321
1453
  },
1322
1454
  apiKey: "GOOGLE_API_KEY",
1323
1455
  defaults: {
1324
1456
  searchModel: DEFAULT_SEARCH_MODEL,
1457
+ contentsModel: DEFAULT_CONTENTS_MODEL,
1325
1458
  answerModel: DEFAULT_ANSWER_MODEL,
1326
1459
  researchAgent: DEFAULT_RESEARCH_AGENT
1327
1460
  }
@@ -1340,11 +1473,15 @@ var GeminiProvider = class {
1340
1473
  }
1341
1474
  return { available: true, summary: "enabled" };
1342
1475
  }
1343
- async search(query2, maxResults, config, context) {
1476
+ async search(query2, maxResults, options, config, context) {
1344
1477
  const ai = this.createClient(config);
1345
- const model = config.defaults?.searchModel ?? DEFAULT_SEARCH_MODEL;
1478
+ const request = buildGeminiSearchRequest(
1479
+ query2,
1480
+ config.defaults?.searchModel ?? DEFAULT_SEARCH_MODEL,
1481
+ options
1482
+ );
1346
1483
  context.onProgress?.(`Searching Gemini for: ${query2}`);
1347
- const interaction = await createSearchInteraction(ai, model, query2);
1484
+ const interaction = await createSearchInteraction(ai, request);
1348
1485
  const results = await Promise.all(
1349
1486
  extractGoogleSearchResults(interaction.outputs).slice(0, maxResults).map(async (result) => {
1350
1487
  const resolvedUrl = await resolveGoogleSearchUrl(result.url);
@@ -1360,17 +1497,66 @@ var GeminiProvider = class {
1360
1497
  results
1361
1498
  };
1362
1499
  }
1500
+ async contents(urls, options, config, context) {
1501
+ const ai = this.createClient(config);
1502
+ context.onProgress?.(
1503
+ `Fetching contents from Gemini for ${urls.length} URL(s)`
1504
+ );
1505
+ const urlList = urls.map((url) => `- ${url}`).join("\n");
1506
+ const request = buildGeminiGenerateContentRequest({
1507
+ defaultModel: config.defaults?.contentsModel ?? DEFAULT_CONTENTS_MODEL,
1508
+ prompt: `Extract the main textual content from each of the following URLs. For each URL, return the page title followed by the cleaned body text. Preserve the original structure (headings, paragraphs, lists) but remove navigation, ads, and boilerplate.
1509
+
1510
+ ${urlList}`,
1511
+ options,
1512
+ toolConfig: { urlContext: {} }
1513
+ });
1514
+ const response = await ai.models.generateContent({
1515
+ model: request.model,
1516
+ contents: [request.contents],
1517
+ config: request.config
1518
+ });
1519
+ const text = response.text?.trim() || "";
1520
+ const metadata = extractUrlContextMetadata(response.candidates);
1521
+ const lines = [];
1522
+ if (text) {
1523
+ lines.push(text);
1524
+ }
1525
+ if (metadata.length > 0) {
1526
+ const failures = metadata.filter(
1527
+ (entry) => entry.status !== "URL_RETRIEVAL_STATUS_SUCCESS" && entry.status !== void 0
1528
+ );
1529
+ if (failures.length > 0) {
1530
+ lines.push("");
1531
+ lines.push("Retrieval issues:");
1532
+ for (const failure of failures) {
1533
+ lines.push(`- ${failure.url}: ${failure.status}`);
1534
+ }
1535
+ }
1536
+ }
1537
+ const successCount = metadata.filter(
1538
+ (entry) => entry.status === "URL_RETRIEVAL_STATUS_SUCCESS" || entry.status === void 0
1539
+ ).length;
1540
+ return {
1541
+ provider: this.id,
1542
+ text: lines.join("\n").trimEnd() || "No contents extracted.",
1543
+ summary: `${successCount} of ${urls.length} URL(s) extracted via Gemini`,
1544
+ itemCount: successCount
1545
+ };
1546
+ }
1363
1547
  async answer(query2, options, config, context) {
1364
1548
  const ai = this.createClient(config);
1365
- const model = config.defaults?.answerModel ?? DEFAULT_ANSWER_MODEL;
1549
+ const request = buildGeminiGenerateContentRequest({
1550
+ defaultModel: config.defaults?.answerModel ?? DEFAULT_ANSWER_MODEL,
1551
+ prompt: query2,
1552
+ options,
1553
+ toolConfig: { googleSearch: {} }
1554
+ });
1366
1555
  context.onProgress?.(`Getting Gemini answer for: ${query2}`);
1367
1556
  const response = await ai.models.generateContent({
1368
- model,
1369
- contents: query2,
1370
- config: {
1371
- ...options ?? {},
1372
- tools: [{ googleSearch: {} }]
1373
- }
1557
+ model: request.model,
1558
+ contents: request.contents,
1559
+ config: request.config
1374
1560
  });
1375
1561
  const lines = [];
1376
1562
  lines.push(response.text?.trim() || "No answer returned.");
@@ -1398,7 +1584,9 @@ var GeminiProvider = class {
1398
1584
  const ai = this.createClient(config);
1399
1585
  const agent = config.defaults?.researchAgent ?? DEFAULT_RESEARCH_AGENT;
1400
1586
  const pollIntervalMs = getPollInterval(options);
1401
- const requestOptions = stripPollIntervalOption(options);
1587
+ const requestOptions = getGeminiResearchRequestOptions(
1588
+ stripPollIntervalOption(options)
1589
+ );
1402
1590
  const startedAt = Date.now();
1403
1591
  let lastStatus;
1404
1592
  context.onProgress?.("Starting Gemini deep research");
@@ -1503,6 +1691,31 @@ function extractGroundingSources(chunks) {
1503
1691
  }
1504
1692
  return sources;
1505
1693
  }
1694
+ function extractUrlContextMetadata(candidates) {
1695
+ const results = [];
1696
+ if (!Array.isArray(candidates)) {
1697
+ return results;
1698
+ }
1699
+ for (const candidate of candidates) {
1700
+ if (typeof candidate !== "object" || candidate === null) {
1701
+ continue;
1702
+ }
1703
+ const metadata = candidate.urlContextMetadata;
1704
+ if (!metadata?.urlMetadata || !Array.isArray(metadata.urlMetadata)) {
1705
+ continue;
1706
+ }
1707
+ for (const entry of metadata.urlMetadata) {
1708
+ if (typeof entry !== "object" || entry === null) {
1709
+ continue;
1710
+ }
1711
+ results.push({
1712
+ url: typeof entry.retrievedUrl === "string" ? entry.retrievedUrl : "unknown",
1713
+ status: typeof entry.urlRetrievalStatus === "string" ? entry.urlRetrievalStatus : void 0
1714
+ });
1715
+ }
1716
+ }
1717
+ return results;
1718
+ }
1506
1719
  function formatInteractionOutputs(outputs) {
1507
1720
  const lines = [];
1508
1721
  if (!Array.isArray(outputs)) {
@@ -1549,24 +1762,31 @@ function isGoogleGroundingRedirect(url) {
1549
1762
  return false;
1550
1763
  }
1551
1764
  }
1552
- async function createSearchInteraction(ai, model, query2) {
1553
- const request = {
1554
- model,
1555
- input: query2,
1556
- tools: [{ type: "google_search" }]
1557
- };
1558
- try {
1559
- return await ai.interactions.create({
1560
- ...request,
1765
+ async function createSearchInteraction(ai, request) {
1766
+ const forcedRequest = {
1767
+ ...request,
1768
+ ...request.generation_config ? {
1561
1769
  generation_config: {
1770
+ ...request.generation_config,
1562
1771
  tool_choice: "any"
1563
1772
  }
1564
- });
1773
+ } : {
1774
+ generation_config: {
1775
+ tool_choice: "any"
1776
+ }
1777
+ }
1778
+ };
1779
+ try {
1780
+ return await ai.interactions.create(forcedRequest);
1565
1781
  } catch (error) {
1566
1782
  if (!isBuiltInToolChoiceError(error)) {
1567
1783
  throw error;
1568
1784
  }
1569
- return ai.interactions.create(request);
1785
+ const fallbackGenerationConfig = stripToolChoice(request.generation_config);
1786
+ return ai.interactions.create({
1787
+ ...request,
1788
+ ...fallbackGenerationConfig ? { generation_config: fallbackGenerationConfig } : {}
1789
+ });
1570
1790
  }
1571
1791
  }
1572
1792
  function isBuiltInToolChoiceError(error) {
@@ -1639,6 +1859,244 @@ function stripPollIntervalOption(options) {
1639
1859
  const { pollIntervalMs: _ignored, ...rest } = options;
1640
1860
  return rest;
1641
1861
  }
1862
+ function buildGeminiSearchRequest(query2, defaultModel, options) {
1863
+ return {
1864
+ model: readNonEmptyString3(options?.model) ?? defaultModel,
1865
+ input: query2,
1866
+ tools: [{ type: "google_search" }],
1867
+ ...isPlainObject3(options?.generation_config) ? { generation_config: options.generation_config } : {}
1868
+ };
1869
+ }
1870
+ function buildGeminiGenerateContentRequest({
1871
+ defaultModel,
1872
+ prompt,
1873
+ options,
1874
+ toolConfig
1875
+ }) {
1876
+ const requestOptions = isPlainObject3(options) ? options : {};
1877
+ const explicitConfig = isPlainObject3(requestOptions.config) ? requestOptions.config : {};
1878
+ return {
1879
+ model: readNonEmptyString3(requestOptions.model) ?? defaultModel,
1880
+ contents: prompt,
1881
+ config: {
1882
+ ...explicitConfig,
1883
+ tools: [toolConfig]
1884
+ }
1885
+ };
1886
+ }
1887
+ function getGeminiResearchRequestOptions(options) {
1888
+ if (!isPlainObject3(options)) {
1889
+ return {};
1890
+ }
1891
+ return { ...options };
1892
+ }
1893
+ function stripToolChoice(generationConfig) {
1894
+ if (!generationConfig || !Object.hasOwn(generationConfig, "tool_choice")) {
1895
+ return generationConfig;
1896
+ }
1897
+ const { tool_choice: _ignored, ...rest } = generationConfig;
1898
+ return Object.keys(rest).length > 0 ? rest : void 0;
1899
+ }
1900
+ function isPlainObject3(value) {
1901
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1902
+ }
1903
+ function readNonEmptyString3(value) {
1904
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
1905
+ }
1906
+
1907
+ // src/providers/perplexity.ts
1908
+ import Perplexity from "@perplexity-ai/perplexity_ai";
1909
+ var DEFAULT_ANSWER_MODEL2 = "sonar";
1910
+ var DEFAULT_RESEARCH_MODEL = "sonar-deep-research";
1911
+ var PerplexityProvider = class {
1912
+ id = "perplexity";
1913
+ label = "Perplexity";
1914
+ docsUrl = "https://docs.perplexity.ai/docs/sdk/overview.md";
1915
+ createTemplate() {
1916
+ return {
1917
+ enabled: false,
1918
+ tools: {
1919
+ search: true,
1920
+ answer: true,
1921
+ research: true
1922
+ },
1923
+ apiKey: "PERPLEXITY_API_KEY",
1924
+ defaults: {
1925
+ answer: {
1926
+ model: DEFAULT_ANSWER_MODEL2
1927
+ },
1928
+ research: {
1929
+ model: DEFAULT_RESEARCH_MODEL
1930
+ }
1931
+ }
1932
+ };
1933
+ }
1934
+ getStatus(config) {
1935
+ if (!config) {
1936
+ return { available: false, summary: "not configured" };
1937
+ }
1938
+ if (config.enabled === false) {
1939
+ return { available: false, summary: "disabled" };
1940
+ }
1941
+ const apiKey = resolveConfigValue(config.apiKey);
1942
+ if (!apiKey) {
1943
+ return { available: false, summary: "missing apiKey" };
1944
+ }
1945
+ return { available: true, summary: "enabled" };
1946
+ }
1947
+ async search(query2, maxResults, options, config, context) {
1948
+ const client = this.createClient(config);
1949
+ const request = {
1950
+ ...asJsonObject(config.defaults?.search),
1951
+ ...options ?? {},
1952
+ query: query2,
1953
+ max_results: maxResults
1954
+ };
1955
+ context.onProgress?.(`Searching Perplexity for: ${query2}`);
1956
+ const response = await client.search.create(
1957
+ request,
1958
+ buildRequestOptions(context)
1959
+ );
1960
+ return {
1961
+ provider: this.id,
1962
+ results: response.results.slice(0, maxResults).map((result) => ({
1963
+ title: result.title,
1964
+ url: result.url,
1965
+ snippet: trimSnippet(result.snippet),
1966
+ metadata: result.date || result.last_updated ? {
1967
+ ...result.date ? { date: result.date } : {},
1968
+ ...result.last_updated ? { last_updated: result.last_updated } : {}
1969
+ } : void 0
1970
+ }))
1971
+ };
1972
+ }
1973
+ async answer(query2, options, config, context) {
1974
+ context.onProgress?.(`Getting Perplexity answer for: ${query2}`);
1975
+ return this.runChatTool(
1976
+ query2,
1977
+ options,
1978
+ config,
1979
+ context,
1980
+ DEFAULT_ANSWER_MODEL2,
1981
+ "Answer"
1982
+ );
1983
+ }
1984
+ async research(input, options, config, context) {
1985
+ context.onProgress?.("Starting Perplexity research");
1986
+ return this.runChatTool(
1987
+ input,
1988
+ options,
1989
+ config,
1990
+ context,
1991
+ DEFAULT_RESEARCH_MODEL,
1992
+ "Research",
1993
+ true
1994
+ );
1995
+ }
1996
+ async runChatTool(input, options, config, context, fallbackModel, label, isResearch = false) {
1997
+ const client = this.createClient(config);
1998
+ const defaults = isResearch ? config.defaults?.research : config.defaults?.answer;
1999
+ const request = {
2000
+ ...asJsonObject(defaults),
2001
+ ...options ?? {},
2002
+ messages: [{ role: "user", content: input }],
2003
+ model: resolveModel(
2004
+ (options ?? {}).model,
2005
+ asJsonObject(defaults).model,
2006
+ fallbackModel
2007
+ ) ?? fallbackModel,
2008
+ stream: false
2009
+ };
2010
+ const response = await client.chat.completions.create(
2011
+ request,
2012
+ buildRequestOptions(context)
2013
+ );
2014
+ const content = extractMessageText(response.choices[0]?.message?.content);
2015
+ const sources = dedupeSources(extractSources(response));
2016
+ const lines = [];
2017
+ lines.push(content || `No ${label.toLowerCase()} returned.`);
2018
+ if (sources.length > 0) {
2019
+ lines.push("");
2020
+ lines.push("Sources:");
2021
+ for (const [index, source] of sources.entries()) {
2022
+ lines.push(`${index + 1}. ${source.title}`);
2023
+ lines.push(` ${source.url}`);
2024
+ }
2025
+ }
2026
+ return {
2027
+ provider: this.id,
2028
+ text: lines.join("\n").trimEnd(),
2029
+ summary: `${label} via Perplexity with ${sources.length} source(s)`,
2030
+ itemCount: sources.length
2031
+ };
2032
+ }
2033
+ createClient(config) {
2034
+ const apiKey = resolveConfigValue(config.apiKey);
2035
+ if (!apiKey) {
2036
+ throw new Error("Perplexity is missing an API key.");
2037
+ }
2038
+ return new Perplexity({
2039
+ apiKey,
2040
+ baseURL: resolveConfigValue(config.baseUrl)
2041
+ });
2042
+ }
2043
+ };
2044
+ function resolveModel(optionModel, defaultModel, fallbackModel) {
2045
+ if (typeof optionModel === "string" && optionModel.trim().length > 0) {
2046
+ return optionModel;
2047
+ }
2048
+ if (typeof defaultModel === "string" && defaultModel.trim().length > 0) {
2049
+ return defaultModel;
2050
+ }
2051
+ return fallbackModel;
2052
+ }
2053
+ function extractMessageText(content) {
2054
+ if (typeof content === "string") {
2055
+ return content.trim();
2056
+ }
2057
+ if (!Array.isArray(content)) {
2058
+ return "";
2059
+ }
2060
+ return content.flatMap((chunk) => {
2061
+ if (typeof chunk === "object" && chunk !== null && "type" in chunk && chunk.type === "text" && "text" in chunk && typeof chunk.text === "string") {
2062
+ return [chunk.text.trim()];
2063
+ }
2064
+ return [];
2065
+ }).filter((text) => text.length > 0).join("\n\n").trim();
2066
+ }
2067
+ function dedupeSources(sources) {
2068
+ const seen = /* @__PURE__ */ new Set();
2069
+ const unique = [];
2070
+ for (const source of sources) {
2071
+ const title = source.title.trim() || source.url.trim() || "Untitled";
2072
+ const url = source.url.trim();
2073
+ if (!url) continue;
2074
+ const key = `${title.toLowerCase()}::${url.toLowerCase()}`;
2075
+ if (seen.has(key)) continue;
2076
+ seen.add(key);
2077
+ unique.push({ title, url });
2078
+ }
2079
+ return unique;
2080
+ }
2081
+ function extractSources(response) {
2082
+ const searchResults = response.search_results?.flatMap((result) => {
2083
+ const url = result.url?.trim() ?? "";
2084
+ if (!url) {
2085
+ return [];
2086
+ }
2087
+ return [{ title: result.title?.trim() ?? url, url }];
2088
+ }) ?? [];
2089
+ if (searchResults.length > 0) {
2090
+ return searchResults;
2091
+ }
2092
+ return response.citations?.flatMap((citation) => {
2093
+ const url = citation?.trim() ?? "";
2094
+ return url ? [{ title: url, url }] : [];
2095
+ }) ?? [];
2096
+ }
2097
+ function buildRequestOptions(context) {
2098
+ return context.signal ? { signal: context.signal } : void 0;
2099
+ }
1642
2100
 
1643
2101
  // src/providers/parallel.ts
1644
2102
  import Parallel from "parallel-web";
@@ -1678,12 +2136,13 @@ var ParallelProvider = class {
1678
2136
  }
1679
2137
  return { available: true, summary: "enabled" };
1680
2138
  }
1681
- async search(query2, maxResults, config, context) {
2139
+ async search(query2, maxResults, options, config, context) {
1682
2140
  const client = this.createClient(config);
1683
2141
  const defaults = asJsonObject(config.defaults?.search);
1684
2142
  context.onProgress?.(`Searching Parallel for: ${query2}`);
1685
2143
  const response = await client.beta.search({
1686
2144
  ...defaults,
2145
+ ...options ?? {},
1687
2146
  objective: query2,
1688
2147
  max_results: maxResults
1689
2148
  });
@@ -1781,7 +2240,7 @@ var ValyuProvider = class {
1781
2240
  }
1782
2241
  return { available: true, summary: "enabled" };
1783
2242
  }
1784
- async search(query2, maxResults, config, context) {
2243
+ async search(query2, maxResults, searchOptions, config, context) {
1785
2244
  const apiKey = resolveConfigValue(config.apiKey);
1786
2245
  if (!apiKey) {
1787
2246
  throw new Error("Valyu is missing an API key.");
@@ -1789,6 +2248,7 @@ var ValyuProvider = class {
1789
2248
  const client = new Valyu(apiKey, config.baseUrl);
1790
2249
  const options = {
1791
2250
  ...asJsonObject(config.defaults),
2251
+ ...searchOptions ?? {},
1792
2252
  maxNumResults: maxResults
1793
2253
  };
1794
2254
  context.onProgress?.(`Searching Valyu for: ${query2}`);
@@ -1938,6 +2398,7 @@ var PROVIDERS = [
1938
2398
  new CodexProvider(),
1939
2399
  new ExaProvider(),
1940
2400
  new GeminiProvider(),
2401
+ new PerplexityProvider(),
1941
2402
  new ParallelProvider(),
1942
2403
  new ValyuProvider()
1943
2404
  ];
@@ -1946,8 +2407,9 @@ var PROVIDER_MAP = {
1946
2407
  codex: PROVIDERS[1],
1947
2408
  exa: PROVIDERS[2],
1948
2409
  gemini: PROVIDERS[3],
1949
- parallel: PROVIDERS[4],
1950
- valyu: PROVIDERS[5]
2410
+ perplexity: PROVIDERS[4],
2411
+ parallel: PROVIDERS[5],
2412
+ valyu: PROVIDERS[6]
1951
2413
  };
1952
2414
 
1953
2415
  // src/provider-resolution.ts
@@ -2025,6 +2487,7 @@ var PROVIDER_IDS = [
2025
2487
  "codex",
2026
2488
  "exa",
2027
2489
  "gemini",
2490
+ "perplexity",
2028
2491
  "parallel",
2029
2492
  "valyu"
2030
2493
  ];
@@ -2093,6 +2556,7 @@ function registerWebSearchTool(pi, providerIds) {
2093
2556
  description: `Maximum number of results to return (default: ${DEFAULT_MAX_RESULTS})`
2094
2557
  })
2095
2558
  ),
2559
+ options: jsonOptionsSchema("Provider-specific search options."),
2096
2560
  provider: providerEnum(
2097
2561
  visibleProviderIds,
2098
2562
  "Provider override. If omitted, uses the active configured provider or falls back to Codex for search when it is not explicitly disabled."
@@ -2109,6 +2573,7 @@ function registerWebSearchTool(pi, providerIds) {
2109
2573
  const response = await provider.search(
2110
2574
  params.query,
2111
2575
  maxResults,
2576
+ normalizeOptions(params.options),
2112
2577
  providerConfig,
2113
2578
  {
2114
2579
  cwd: ctx.cwd,
@@ -2671,6 +3136,11 @@ function buildProviderMenuOptions(providerId) {
2671
3136
  "Search model",
2672
3137
  "Model used for Gemini search interactions."
2673
3138
  );
3139
+ pushText(
3140
+ "geminiContentsModel",
3141
+ "Contents model",
3142
+ "Model used for Gemini URL content extraction via URL Context."
3143
+ );
2674
3144
  pushText(
2675
3145
  "geminiAnswerModel",
2676
3146
  "Answer model",
@@ -2683,6 +3153,9 @@ function buildProviderMenuOptions(providerId) {
2683
3153
  );
2684
3154
  return options;
2685
3155
  }
3156
+ if (providerId === "perplexity") {
3157
+ return options;
3158
+ }
2686
3159
  if (providerId === "parallel") {
2687
3160
  pushValues(
2688
3161
  "parallelSearchMode",
@@ -2862,7 +3335,7 @@ var WebProvidersSettingsView = class {
2862
3335
  ) : key === "model" || key === "additionalDirectories" ? getCodexTextSettingValue(
2863
3336
  providerConfig,
2864
3337
  key
2865
- ) : key === "geminiSearchModel" || key === "geminiAnswerModel" || key === "geminiResearchAgent" ? getGeminiTextSettingValue(
3338
+ ) : key === "geminiSearchModel" || key === "geminiContentsModel" || key === "geminiAnswerModel" || key === "geminiResearchAgent" ? getGeminiTextSettingValue(
2866
3339
  providerConfig,
2867
3340
  key
2868
3341
  ) : getProviderStringValue(
@@ -3002,7 +3475,7 @@ var WebProvidersSettingsView = class {
3002
3475
  id
3003
3476
  );
3004
3477
  }
3005
- if (id === "geminiSearchModel" || id === "geminiAnswerModel" || id === "geminiResearchAgent") {
3478
+ if (id === "geminiSearchModel" || id === "geminiContentsModel" || id === "geminiAnswerModel" || id === "geminiResearchAgent") {
3006
3479
  return getGeminiTextSettingValue(
3007
3480
  providerConfig,
3008
3481
  id
@@ -3271,6 +3744,7 @@ function getGeminiTextSettingValue(config, key) {
3271
3744
  const defaults = config?.defaults;
3272
3745
  if (!defaults) return void 0;
3273
3746
  if (key === "geminiSearchModel") return defaults.searchModel;
3747
+ if (key === "geminiContentsModel") return defaults.contentsModel;
3274
3748
  if (key === "geminiAnswerModel") return defaults.answerModel;
3275
3749
  return defaults.researchAgent;
3276
3750
  }
@@ -3442,6 +3916,14 @@ function applyGeminiSettingChange(target, key, value) {
3442
3916
  );
3443
3917
  cleanupGeminiDefaults(target);
3444
3918
  return true;
3919
+ case "geminiContentsModel":
3920
+ assignOptionalString(
3921
+ target.defaults,
3922
+ "contentsModel",
3923
+ value
3924
+ );
3925
+ cleanupGeminiDefaults(target);
3926
+ return true;
3445
3927
  case "geminiAnswerModel":
3446
3928
  assignOptionalString(
3447
3929
  target.defaults,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-web-providers",
3
- "version": "0.2.0",
4
- "description": "Configurable web access extension for pi that routes search, contents, answers, and research across Claude, Codex, Exa, Gemini, Parallel, and Valyu providers.",
3
+ "version": "0.3.0",
4
+ "description": "Configurable web access extension for pi that routes search, contents, answers, and research across Claude, Codex, Exa, Gemini, Perplexity, Parallel, and Valyu providers.",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist",
@@ -17,6 +17,7 @@
17
17
  "codex",
18
18
  "exa",
19
19
  "gemini",
20
+ "perplexity",
20
21
  "parallel",
21
22
  "valyu"
22
23
  ],
@@ -38,7 +39,7 @@
38
39
  ]
39
40
  },
40
41
  "scripts": {
41
- "build": "rm -rf dist && esbuild src/index.ts --bundle --format=esm --platform=node --outfile=dist/index.js --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-ai --external:@mariozechner/pi-tui --external:@sinclair/typebox --external:@anthropic-ai/claude-agent-sdk --external:@google/genai --external:@openai/codex-sdk --external:exa-js --external:parallel-web --external:valyu-js",
42
+ "build": "rm -rf dist && esbuild src/index.ts --bundle --format=esm --platform=node --outfile=dist/index.js --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-ai --external:@mariozechner/pi-tui --external:@sinclair/typebox --external:@anthropic-ai/claude-agent-sdk --external:@google/genai --external:@openai/codex-sdk --external:@perplexity-ai/perplexity_ai --external:exa-js --external:parallel-web --external:valyu-js",
42
43
  "prepare": "npm run build",
43
44
  "prepack": "npm run build",
44
45
  "check": "tsc --noEmit",
@@ -51,6 +52,7 @@
51
52
  "@anthropic-ai/claude-agent-sdk": "^0.2.71",
52
53
  "@google/genai": "^1.44.0",
53
54
  "@openai/codex-sdk": "^0.111.0",
55
+ "@perplexity-ai/perplexity_ai": "^0.26.1",
54
56
  "exa-js": "^2.7.0",
55
57
  "parallel-web": "^0.3.1",
56
58
  "valyu-js": "^2.5.9",