@pi-unipi/web-api 0.1.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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * @unipi/web-api — Settings storage
3
+ *
4
+ * Manages API keys and provider configuration.
5
+ * Persists to ~/.unipi/config/web-api/auth.json and config.json
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import * as os from "node:os";
11
+
12
+ /** Auth storage structure (API keys) */
13
+ export interface WebApiAuth {
14
+ [providerId: string]: string;
15
+ }
16
+
17
+ /** Provider configuration */
18
+ export interface ProviderSettings {
19
+ enabled: boolean;
20
+ apiKey?: string;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ /** Cache configuration */
25
+ export interface CacheSettings {
26
+ enabled: boolean;
27
+ ttlMs: number;
28
+ }
29
+
30
+ /** Config storage structure */
31
+ export interface WebApiConfig {
32
+ providers: Record<string, ProviderSettings>;
33
+ cache: CacheSettings;
34
+ }
35
+
36
+ /** Default configuration */
37
+ const DEFAULT_CONFIG: WebApiConfig = {
38
+ providers: {
39
+ duckduckgo: { enabled: true },
40
+ "jina-search": { enabled: true },
41
+ "jina-reader": { enabled: true },
42
+ serpapi: { enabled: false },
43
+ tavily: { enabled: false },
44
+ firecrawl: { enabled: false },
45
+ perplexity: { enabled: false },
46
+ "llm-summarize": { enabled: true },
47
+ },
48
+ cache: {
49
+ enabled: true,
50
+ ttlMs: 3600000, // 1 hour
51
+ },
52
+ };
53
+
54
+ /**
55
+ * Get the config directory path.
56
+ */
57
+ function getConfigDir(): string {
58
+ const homeDir = os.homedir();
59
+ return path.join(homeDir, ".unipi", "config", "web-api");
60
+ }
61
+
62
+ /**
63
+ * Get the auth file path.
64
+ */
65
+ function getAuthPath(): string {
66
+ return path.join(getConfigDir(), "auth.json");
67
+ }
68
+
69
+ /**
70
+ * Get the config file path.
71
+ */
72
+ function getConfigPath(): string {
73
+ return path.join(getConfigDir(), "config.json");
74
+ }
75
+
76
+ /**
77
+ * Ensure config directory exists.
78
+ */
79
+ function ensureConfigDir(): void {
80
+ const dir = getConfigDir();
81
+ if (!fs.existsSync(dir)) {
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Load API keys from auth.json.
88
+ * @returns API keys object
89
+ */
90
+ export function loadAuth(): WebApiAuth {
91
+ try {
92
+ const authPath = getAuthPath();
93
+ if (fs.existsSync(authPath)) {
94
+ const content = fs.readFileSync(authPath, "utf-8");
95
+ return JSON.parse(content);
96
+ }
97
+ } catch (error) {
98
+ console.error("[web-api] Failed to load auth:", error);
99
+ }
100
+ return {};
101
+ }
102
+
103
+ /**
104
+ * Save API keys to auth.json.
105
+ * @param auth - API keys object
106
+ */
107
+ export function saveAuth(auth: WebApiAuth): void {
108
+ ensureConfigDir();
109
+ const authPath = getAuthPath();
110
+ fs.writeFileSync(authPath, JSON.stringify(auth, null, 2), "utf-8");
111
+ }
112
+
113
+ /**
114
+ * Load configuration from config.json.
115
+ * @returns Configuration object
116
+ */
117
+ export function loadConfig(): WebApiConfig {
118
+ try {
119
+ const configPath = getConfigPath();
120
+ if (fs.existsSync(configPath)) {
121
+ const content = fs.readFileSync(configPath, "utf-8");
122
+ const config = JSON.parse(content) as Partial<WebApiConfig>;
123
+ return {
124
+ ...DEFAULT_CONFIG,
125
+ ...config,
126
+ providers: {
127
+ ...DEFAULT_CONFIG.providers,
128
+ ...config.providers,
129
+ },
130
+ cache: {
131
+ ...DEFAULT_CONFIG.cache,
132
+ ...config.cache,
133
+ },
134
+ };
135
+ }
136
+ } catch (error) {
137
+ console.error("[web-api] Failed to load config:", error);
138
+ }
139
+ return DEFAULT_CONFIG;
140
+ }
141
+
142
+ /**
143
+ * Save configuration to config.json.
144
+ * @param config - Configuration object
145
+ */
146
+ export function saveConfig(config: WebApiConfig): void {
147
+ ensureConfigDir();
148
+ const configPath = getConfigPath();
149
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
150
+ }
151
+
152
+ /**
153
+ * Get API key for a provider.
154
+ * @param providerId - Provider ID
155
+ * @returns API key or undefined
156
+ */
157
+ export function getApiKey(providerId: string): string | undefined {
158
+ const auth = loadAuth();
159
+ return auth[providerId];
160
+ }
161
+
162
+ /**
163
+ * Set API key for a provider.
164
+ * @param providerId - Provider ID
165
+ * @param apiKey - API key
166
+ */
167
+ export function setApiKey(providerId: string, apiKey: string): void {
168
+ const auth = loadAuth();
169
+ auth[providerId] = apiKey;
170
+ saveAuth(auth);
171
+ }
172
+
173
+ /**
174
+ * Remove API key for a provider.
175
+ * @param providerId - Provider ID
176
+ */
177
+ export function removeApiKey(providerId: string): void {
178
+ const auth = loadAuth();
179
+ delete auth[providerId];
180
+ saveAuth(auth);
181
+ }
182
+
183
+ /**
184
+ * Check if a provider is enabled.
185
+ * @param providerId - Provider ID
186
+ * @returns true if enabled
187
+ */
188
+ export function isProviderEnabled(providerId: string): boolean {
189
+ const config = loadConfig();
190
+ return config.providers[providerId]?.enabled !== false;
191
+ }
192
+
193
+ /**
194
+ * Enable or disable a provider.
195
+ * @param providerId - Provider ID
196
+ * @param enabled - Whether to enable
197
+ */
198
+ export function setProviderEnabled(providerId: string, enabled: boolean): void {
199
+ const config = loadConfig();
200
+ if (!config.providers[providerId]) {
201
+ config.providers[providerId] = { enabled };
202
+ } else {
203
+ config.providers[providerId].enabled = enabled;
204
+ }
205
+ saveConfig(config);
206
+ }
207
+
208
+ /**
209
+ * Get cache settings.
210
+ * @returns Cache configuration
211
+ */
212
+ export function getCacheSettings(): CacheSettings {
213
+ const config = loadConfig();
214
+ return config.cache;
215
+ }
216
+
217
+ /**
218
+ * Update cache settings.
219
+ * @param cache - New cache settings
220
+ */
221
+ export function updateCacheSettings(cache: Partial<CacheSettings>): void {
222
+ const config = loadConfig();
223
+ config.cache = {
224
+ ...config.cache,
225
+ ...cache,
226
+ };
227
+ saveConfig(config);
228
+ }
229
+
230
+ /**
231
+ * Validate API key format (basic validation).
232
+ * @param providerId - Provider ID
233
+ * @param apiKey - API key to validate
234
+ * @returns true if format looks valid
235
+ */
236
+ export function validateApiKeyFormat(providerId: string, apiKey: string): boolean {
237
+ if (!apiKey || apiKey.trim().length === 0) {
238
+ return false;
239
+ }
240
+
241
+ // Provider-specific format checks
242
+ switch (providerId) {
243
+ case "serpapi":
244
+ // SerpAPI keys are typically 64 characters
245
+ return apiKey.length >= 32;
246
+ case "tavily":
247
+ // Tavily keys start with "tvly-"
248
+ return apiKey.startsWith("tvly-") && apiKey.length >= 10;
249
+ case "firecrawl":
250
+ // Firecrawl keys are typically longer
251
+ return apiKey.length >= 20;
252
+ case "perplexity":
253
+ // Perplexity keys are typically longer
254
+ return apiKey.length >= 20;
255
+ case "jina-search":
256
+ case "jina-reader":
257
+ // Jina keys are typically longer
258
+ return apiKey.length >= 10;
259
+ default:
260
+ // Generic validation
261
+ return apiKey.length >= 8;
262
+ }
263
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * @unipi/web-api — Agent tools registration
3
+ *
4
+ * Registers web-search, web-read, and web-llm-summarize tools.
5
+ * Implements smart provider selection based on ranking.
6
+ */
7
+
8
+ import { Type } from "@sinclair/typebox";
9
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import { registry } from "./providers/registry.js";
11
+ import type {
12
+ WebProvider,
13
+ WebCapability,
14
+ SearchResult,
15
+ ReadResult,
16
+ SummarizeResult,
17
+ } from "./providers/base.js";
18
+ import {
19
+ getApiKey,
20
+ isProviderEnabled,
21
+ loadConfig,
22
+ } from "./settings.js";
23
+
24
+ /** Tool names */
25
+ export const WEB_TOOLS = {
26
+ SEARCH: "web_search",
27
+ READ: "web_read",
28
+ SUMMARIZE: "web_llm_summarize",
29
+ } as const;
30
+
31
+ /**
32
+ * Get available providers for a capability.
33
+ * Filters by enabled status and API key availability.
34
+ */
35
+ function getAvailableProviders(capability: WebCapability): WebProvider[] {
36
+ const config = loadConfig();
37
+ const ranked = registry.getRankedProviders(capability);
38
+
39
+ return ranked.filter((provider) => {
40
+ // Check if provider is enabled
41
+ if (!isProviderEnabled(provider.id)) {
42
+ return false;
43
+ }
44
+
45
+ // Check if provider requires API key
46
+ if (provider.requiresApiKey) {
47
+ const apiKey = getApiKey(provider.id);
48
+ if (!apiKey) {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ return true;
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Select provider for a capability.
59
+ * If sourceRank is specified, use that rank.
60
+ * Otherwise, use the lowest-ranked available provider.
61
+ */
62
+ function selectProvider(
63
+ capability: WebCapability,
64
+ sourceRank?: number
65
+ ): WebProvider {
66
+ const available = getAvailableProviders(capability);
67
+
68
+ if (available.length === 0) {
69
+ const allProviders = registry.getProvidersForCapability(capability);
70
+ const providerNames = allProviders.map((p) => p.name).join(", ");
71
+ throw new Error(
72
+ `No ${capability} provider available.\n` +
73
+ `Configure providers via /unipi:web-settings\n` +
74
+ `Available providers: ${providerNames}`
75
+ );
76
+ }
77
+
78
+ if (sourceRank !== undefined) {
79
+ // Find provider with matching rank
80
+ const provider = available.find((p) => p.ranking[capability] === sourceRank);
81
+ if (!provider) {
82
+ const availableRanks = available.map((p) => p.ranking[capability]).join(", ");
83
+ throw new Error(
84
+ `No provider at rank ${sourceRank} for ${capability}.\n` +
85
+ `Available ranks: ${availableRanks}`
86
+ );
87
+ }
88
+ return provider;
89
+ }
90
+
91
+ // Return lowest-ranked (cheapest/simplest) available provider
92
+ return available[0];
93
+ }
94
+
95
+ /**
96
+ * Execute web search.
97
+ */
98
+ async function executeSearch(
99
+ query: string,
100
+ sourceRank?: number
101
+ ): Promise<SearchResult[]> {
102
+ const provider = selectProvider("search", sourceRank);
103
+
104
+ if (!provider.search) {
105
+ throw new Error(`Provider "${provider.name}" does not support search`);
106
+ }
107
+
108
+ const apiKey = provider.requiresApiKey ? getApiKey(provider.id) : undefined;
109
+ const config = { enabled: true, apiKey };
110
+
111
+ return provider.search(query, config);
112
+ }
113
+
114
+ /**
115
+ * Execute web read.
116
+ */
117
+ async function executeRead(
118
+ url: string,
119
+ sourceRank?: number
120
+ ): Promise<ReadResult> {
121
+ const provider = selectProvider("read", sourceRank);
122
+
123
+ if (!provider.read) {
124
+ throw new Error(`Provider "${provider.name}" does not support read`);
125
+ }
126
+
127
+ const apiKey = provider.requiresApiKey ? getApiKey(provider.id) : undefined;
128
+ const config = { enabled: true, apiKey };
129
+
130
+ return provider.read(url, config);
131
+ }
132
+
133
+ /**
134
+ * Execute web summarize.
135
+ */
136
+ async function executeSummarize(
137
+ url: string,
138
+ prompt?: string,
139
+ sourceRank?: number
140
+ ): Promise<SummarizeResult> {
141
+ const provider = selectProvider("summarize", sourceRank);
142
+
143
+ if (!provider.summarize) {
144
+ throw new Error(`Provider "${provider.name}" does not support summarize`);
145
+ }
146
+
147
+ const apiKey = provider.requiresApiKey ? getApiKey(provider.id) : undefined;
148
+ const config = { enabled: true, apiKey };
149
+
150
+ return provider.summarize(url, prompt, config);
151
+ }
152
+
153
+ /**
154
+ * Register web tools with pi.
155
+ */
156
+ export function registerWebTools(pi: ExtensionAPI): void {
157
+ // --- web_search tool ---
158
+ pi.registerTool({
159
+ name: WEB_TOOLS.SEARCH,
160
+ label: "Web Search",
161
+ description:
162
+ "Search the web for information using various providers. " +
163
+ "Lower source = simpler/cheaper providers (DuckDuckGo, Jina Search). " +
164
+ "Higher source = more capable providers (SerpAPI, Tavily, Perplexity).",
165
+ promptSnippet: "Search the web for information.",
166
+ promptGuidelines: [
167
+ "Use web_search to find information on the web.",
168
+ "Omit source for auto-selection (cheapest available).",
169
+ "Specify source number for specific provider (1=DuckDuckGo, 2=Jina, 3=SerpAPI, 4=Tavily, 5=Perplexity).",
170
+ "Quick facts: source 1-2. Research: source 3-5.",
171
+ ],
172
+ parameters: Type.Object({
173
+ query: Type.String({ description: "Search query string" }),
174
+ source: Type.Optional(
175
+ Type.Number({
176
+ description:
177
+ "Provider selection (1=DuckDuckGo, 2=Jina Search, 3=SerpAPI, 4=Tavily, 5=Perplexity). " +
178
+ "Omit for auto-selection.",
179
+ minimum: 1,
180
+ maximum: 5,
181
+ })
182
+ ),
183
+ }),
184
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
185
+ try {
186
+ const results = await executeSearch(params.query, params.source);
187
+
188
+ if (results.length === 0) {
189
+ return {
190
+ content: [{ type: "text", text: "No results found." }],
191
+ };
192
+ }
193
+
194
+ const formatted = results
195
+ .map(
196
+ (r, i) =>
197
+ `${i + 1}. **${r.title}**\n ${r.url}\n ${r.snippet}`
198
+ )
199
+ .join("\n\n");
200
+
201
+ return {
202
+ content: [
203
+ {
204
+ type: "text",
205
+ text: `Found ${results.length} results:\n\n${formatted}`,
206
+ },
207
+ ],
208
+ };
209
+ } catch (error) {
210
+ const message =
211
+ error instanceof Error ? error.message : String(error);
212
+ return {
213
+ content: [{ type: "text", text: `Search failed: ${message}` }],
214
+ isError: true,
215
+ };
216
+ }
217
+ },
218
+ });
219
+
220
+ // --- web_read tool ---
221
+ pi.registerTool({
222
+ name: WEB_TOOLS.READ,
223
+ label: "Web Read",
224
+ description:
225
+ "Read and extract content from a URL. " +
226
+ "Extracts main content, strips navigation/ads. Returns markdown.",
227
+ promptSnippet: "Read content from a URL.",
228
+ promptGuidelines: [
229
+ "Use web_read to extract content from a web page.",
230
+ "Returns main content as markdown.",
231
+ "Lower source = simpler providers (Jina Reader).",
232
+ "Higher source = more capable providers (Firecrawl, Perplexity).",
233
+ ],
234
+ parameters: Type.Object({
235
+ url: Type.String({ description: "URL to read" }),
236
+ source: Type.Optional(
237
+ Type.Number({
238
+ description:
239
+ "Provider selection (1=Jina Reader, 2=Firecrawl, 3=Perplexity). " +
240
+ "Omit for auto-selection.",
241
+ minimum: 1,
242
+ maximum: 3,
243
+ })
244
+ ),
245
+ }),
246
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
247
+ try {
248
+ const result = await executeRead(params.url, params.source);
249
+
250
+ return {
251
+ content: [
252
+ {
253
+ type: "text",
254
+ text: `Content from ${result.url}:\n\n${result.content}`,
255
+ },
256
+ ],
257
+ };
258
+ } catch (error) {
259
+ const message =
260
+ error instanceof Error ? error.message : String(error);
261
+ return {
262
+ content: [{ type: "text", text: `Read failed: ${message}` }],
263
+ isError: true,
264
+ };
265
+ }
266
+ },
267
+ });
268
+
269
+ // --- web_llm_summarize tool ---
270
+ pi.registerTool({
271
+ name: WEB_TOOLS.SUMMARIZE,
272
+ label: "Web LLM Summarize",
273
+ description:
274
+ "Summarize web content using LLM. " +
275
+ "Fetches content from URL, then uses LLM to summarize. " +
276
+ "Higher cost (LLM tokens + provider cost).",
277
+ promptSnippet: "Summarize web content using LLM.",
278
+ promptGuidelines: [
279
+ "Use web_llm_summarize to get a summary of web content.",
280
+ "Specify custom prompt for targeted summaries.",
281
+ "Omit source for auto-selection of content provider.",
282
+ "Higher cost due to LLM token usage.",
283
+ ],
284
+ parameters: Type.Object({
285
+ url: Type.String({ description: "URL to summarize" }),
286
+ prompt: Type.Optional(
287
+ Type.String({
288
+ description:
289
+ "Custom summarization prompt. " +
290
+ "Omit for default comprehensive summary.",
291
+ })
292
+ ),
293
+ source: Type.Optional(
294
+ Type.Number({
295
+ description:
296
+ "Provider selection for content fetch (1=Jina Reader, 2=Firecrawl, 3=Perplexity). " +
297
+ "Omit for auto-selection.",
298
+ minimum: 1,
299
+ maximum: 3,
300
+ })
301
+ ),
302
+ }),
303
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
304
+ try {
305
+ const result = await executeSummarize(
306
+ params.url,
307
+ params.prompt,
308
+ params.source
309
+ );
310
+
311
+ return {
312
+ content: [
313
+ {
314
+ type: "text",
315
+ text: `Summary of ${result.url}:\n\n${result.summary}`,
316
+ },
317
+ ],
318
+ };
319
+ } catch (error) {
320
+ const message =
321
+ error instanceof Error ? error.message : String(error);
322
+ return {
323
+ content: [{ type: "text", text: `Summarize failed: ${message}` }],
324
+ isError: true,
325
+ };
326
+ }
327
+ },
328
+ });
329
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @unipi/web-api — Provider selector TUI component
3
+ *
4
+ * Displays provider list with status indicators for API key management.
5
+ */
6
+
7
+ import type { WebProvider } from "../providers/base.js";
8
+ import { registry } from "../providers/registry.js";
9
+ import { getApiKey, isProviderEnabled } from "../settings.js";
10
+
11
+ /** Provider status */
12
+ export interface ProviderStatus {
13
+ provider: WebProvider;
14
+ configured: boolean;
15
+ enabled: boolean;
16
+ hasApiKey: boolean;
17
+ }
18
+
19
+ /**
20
+ * Get status of all providers.
21
+ */
22
+ export function getProviderStatuses(): ProviderStatus[] {
23
+ const providers = registry.getAllProviders();
24
+
25
+ return providers.map((provider) => {
26
+ const hasApiKey = provider.requiresApiKey
27
+ ? !!getApiKey(provider.id)
28
+ : true;
29
+ const enabled = isProviderEnabled(provider.id);
30
+
31
+ return {
32
+ provider,
33
+ configured: hasApiKey && enabled,
34
+ enabled,
35
+ hasApiKey,
36
+ };
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Format provider status for display.
42
+ */
43
+ export function formatProviderStatus(status: ProviderStatus): string {
44
+ const icon = status.configured ? "✓" : "✗";
45
+ const name = status.provider.name.padEnd(20);
46
+ const capabilities = status.provider.capabilities.join(", ");
47
+ const apiKeyStatus = status.provider.requiresApiKey
48
+ ? status.hasApiKey
49
+ ? "API key configured"
50
+ : "API key required"
51
+ : "No API key needed";
52
+
53
+ return `${icon} ${name} ${capabilities.padEnd(30)} ${apiKeyStatus}`;
54
+ }
55
+
56
+ /**
57
+ * Get provider selection options for TUI.
58
+ */
59
+ export function getProviderOptions(): Array<{
60
+ label: string;
61
+ value: string;
62
+ description: string;
63
+ }> {
64
+ const statuses = getProviderStatuses();
65
+
66
+ return statuses.map((status) => ({
67
+ label: formatProviderStatus(status),
68
+ value: status.provider.id,
69
+ description: `${status.provider.name} - ${status.provider.capabilities.join(", ")}`,
70
+ }));
71
+ }