pi-web-search 1.0.2 → 1.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.
package/dist/api.d.ts CHANGED
@@ -15,7 +15,7 @@ export interface StreamResult {
15
15
  groundingMetadata?: any;
16
16
  urlContextMetadata?: any;
17
17
  }
18
- export declare function callApiStream(ctx: ExtensionContext, model: Model<any>, body: any, onUpdate?: AgentToolUpdateCallback): Promise<StreamResult>;
18
+ export declare function callApiStream(ctx: ExtensionContext, model: Model<any>, body: any, onUpdate?: AgentToolUpdateCallback, signal?: AbortSignal): Promise<StreamResult>;
19
19
  export declare function applyCitations(text: string, groundingMetadata: any): {
20
20
  text: string;
21
21
  sources: {
package/dist/api.js CHANGED
@@ -53,27 +53,43 @@ const PROVIDERS = {
53
53
  export function getConfig(model) {
54
54
  return PROVIDERS[model.provider] || PROVIDERS[model.api] || PROVIDERS["google-generative-ai"];
55
55
  }
56
- export async function callApiStream(ctx, model, body, onUpdate) {
56
+ export async function callApiStream(ctx, model, body, onUpdate, signal) {
57
57
  const config = getConfig(model);
58
- const apiKey = await ctx.modelRegistry.getApiKey(model) || "";
58
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
59
+ if (!auth.ok) {
60
+ throw new Error(auth.error || "Failed to get API key and headers");
61
+ }
62
+ // Extract projectId from apiKey for internal Google APIs (gemini-cli, antigravity)
63
+ // These providers return apiKey as JSON: {projectId, token}
59
64
  let projectId;
60
- if (model.api !== "google-generative-ai") {
61
- const parsed = JSON.parse(apiKey);
62
- projectId = parsed.projectId;
65
+ if (model.api !== "google-generative-ai" && auth.apiKey) {
66
+ try {
67
+ const parsed = JSON.parse(auth.apiKey);
68
+ projectId = parsed.projectId;
69
+ }
70
+ catch {
71
+ // Not a JSON string, ignore
72
+ }
63
73
  }
64
74
  const req = config.buildRequest(model, body, projectId);
65
75
  // Handle auth
66
- if (model.api === "google-generative-ai") {
67
- req.headers["x-goog-api-key"] = apiKey;
76
+ if (auth.headers) {
77
+ Object.assign(req.headers, auth.headers);
68
78
  }
69
- else {
70
- const parsed = JSON.parse(apiKey);
71
- req.headers["Authorization"] = `Bearer ${parsed.token}`;
79
+ if (auth.apiKey) {
80
+ if (model.api === "google-generative-ai") {
81
+ req.headers["x-goog-api-key"] = auth.apiKey;
82
+ }
83
+ else {
84
+ const parsed = JSON.parse(auth.apiKey);
85
+ req.headers["Authorization"] = `Bearer ${parsed.token}`;
86
+ }
72
87
  }
73
88
  const response = await fetch(req.url, {
74
89
  method: "POST",
75
90
  headers: req.headers,
76
- body: JSON.stringify(req.body)
91
+ body: JSON.stringify(req.body),
92
+ signal
77
93
  });
78
94
  if (!response.ok) {
79
95
  throw new Error(`API error (${response.status}): ${await response.text()}`);
@@ -88,7 +104,12 @@ export async function callApiStream(ctx, model, body, onUpdate) {
88
104
  let accumulatedText = "";
89
105
  let groundingMetadata;
90
106
  let urlContextMetadata;
107
+ let currentEventData = "";
108
+ let currentEventName = "";
91
109
  while (true) {
110
+ if (signal?.aborted) {
111
+ throw new Error("Request was aborted");
112
+ }
92
113
  const { done, value } = await reader.read();
93
114
  if (done)
94
115
  break;
@@ -96,40 +117,69 @@ export async function callApiStream(ctx, model, body, onUpdate) {
96
117
  const lines = buffer.split("\n");
97
118
  buffer = lines.pop() || "";
98
119
  for (const line of lines) {
99
- if (!line.startsWith("data:"))
100
- continue;
101
- const jsonStr = line.slice(5).trim();
102
- if (!jsonStr)
120
+ // Empty line indicates end of an event
121
+ if (line === "" || line === "\r") {
122
+ if (currentEventData) {
123
+ // Process the complete event
124
+ let chunk;
125
+ try {
126
+ chunk = JSON.parse(currentEventData);
127
+ }
128
+ catch {
129
+ currentEventData = "";
130
+ currentEventName = "";
131
+ continue;
132
+ }
133
+ // Check for error in chunk (API errors are sent via SSE stream)
134
+ if (chunk.error) {
135
+ const errorMsg = chunk.error.message || JSON.stringify(chunk.error);
136
+ throw new Error(`API error (${chunk.error.code || chunk.error.status || 'unknown'}): ${errorMsg}`);
137
+ }
138
+ // Unwrap response for internal APIs
139
+ const data = chunk.response || chunk;
140
+ const candidate = data.candidates?.[0];
141
+ if (candidate?.content?.parts) {
142
+ for (const part of candidate.content.parts) {
143
+ if (part.text) {
144
+ accumulatedText += part.text;
145
+ // Stream update
146
+ onUpdate?.({
147
+ content: [{ type: "text", text: accumulatedText }],
148
+ details: { streaming: true }
149
+ });
150
+ }
151
+ }
152
+ }
153
+ // Capture metadata from final chunk
154
+ if (candidate?.groundingMetadata) {
155
+ groundingMetadata = candidate.groundingMetadata;
156
+ }
157
+ // Handle both camelCase and snake_case
158
+ if (candidate?.urlContextMetadata || candidate?.url_context_metadata) {
159
+ urlContextMetadata = candidate.urlContextMetadata || candidate.url_context_metadata;
160
+ }
161
+ }
162
+ currentEventData = "";
163
+ currentEventName = "";
103
164
  continue;
104
- let chunk;
105
- try {
106
- chunk = JSON.parse(jsonStr);
107
165
  }
108
- catch {
109
- continue;
166
+ // Parse SSE field
167
+ if (line.startsWith("data:")) {
168
+ const data = line.slice(5).trim();
169
+ currentEventData = currentEventData ? currentEventData + "\n" + data : data;
110
170
  }
111
- // Unwrap response for internal APIs
112
- const data = chunk.response || chunk;
113
- const candidate = data.candidates?.[0];
114
- if (candidate?.content?.parts) {
115
- for (const part of candidate.content.parts) {
116
- if (part.text) {
117
- accumulatedText += part.text;
118
- // Stream update
119
- onUpdate?.({
120
- content: [{ type: "text", text: accumulatedText }],
121
- details: { streaming: true }
122
- });
123
- }
171
+ else if (line.startsWith("event:")) {
172
+ currentEventName = line.slice(6).trim();
173
+ // Check for error event type
174
+ if (currentEventName === "error") {
175
+ // Next data line should contain error details
124
176
  }
125
177
  }
126
- // Capture metadata from final chunk
127
- if (candidate?.groundingMetadata) {
128
- groundingMetadata = candidate.groundingMetadata;
178
+ else if (line.startsWith("id:")) {
179
+ // Event ID, can be ignored for now
129
180
  }
130
- // Handle both camelCase and snake_case
131
- if (candidate?.urlContextMetadata || candidate?.url_context_metadata) {
132
- urlContextMetadata = candidate.urlContextMetadata || candidate.url_context_metadata;
181
+ else if (line.startsWith(":")) {
182
+ // Comment line, ignore
133
183
  }
134
184
  }
135
185
  }
@@ -68,7 +68,7 @@ export async function urlContext(id, params, signal, onUpdate, ctx) {
68
68
  const result = await callApiStream(ctx, model, {
69
69
  contents,
70
70
  tools
71
- }, onUpdate);
71
+ }, onUpdate, signal);
72
72
  const { text, sources } = applyCitations(result.text, result.groundingMetadata);
73
73
  // Handle both camelCase and snake_case metadata
74
74
  const urlMeta = result.urlContextMetadata?.urlMetadata
@@ -36,7 +36,7 @@ export async function webSearch(id, params, signal, onUpdate, ctx) {
36
36
  const result = await callApiStream(ctx, model, {
37
37
  contents: [{ role: "user", parts: [{ text: prompt }] }],
38
38
  tools
39
- }, onUpdate);
39
+ }, onUpdate, signal);
40
40
  const { text, sources } = applyCitations(result.text, result.groundingMetadata);
41
41
  // Handle URL context metadata
42
42
  const urlMeta = result.urlContextMetadata?.urlMetadata
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-search",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Web search and content analysis extension for pi, powered by Gemini API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,12 +25,14 @@
25
25
  },
26
26
  "homepage": "https://github.com/ttttmr/pi-web-search#readme",
27
27
  "devDependencies": {
28
- "@mariozechner/pi-coding-agent": "^0.51.5",
28
+ "@mariozechner/pi-coding-agent": "^0.64.0",
29
29
  "@sinclair/typebox": "^0.32.15",
30
- "typescript": "^5.0.0",
31
- "@types/node": "^20.0.0"
30
+ "@types/node": "^20.0.0",
31
+ "typescript": "^5.0.0"
32
32
  },
33
33
  "pi": {
34
- "extensions": ["./src/index.ts"]
34
+ "extensions": [
35
+ "./src/index.ts"
36
+ ]
35
37
  }
36
38
  }
package/src/api.ts CHANGED
@@ -66,6 +66,36 @@ export function getConfig(model: Model<any>): ProviderConfig {
66
66
  return PROVIDERS[model.provider] || PROVIDERS[model.api] || PROVIDERS["google-generative-ai"];
67
67
  }
68
68
 
69
+ // --- Auth Compatibility Layer ---
70
+
71
+ type ResolvedAuth =
72
+ | { ok: true; apiKey?: string; headers?: Record<string, string>; }
73
+ | { ok: false; error: string; };
74
+
75
+ /**
76
+ * Get API key and headers for a model.
77
+ * Compatible with both new pi versions (getApiKeyAndHeaders) and old versions (getApiKey).
78
+ */
79
+ async function getAuth(ctx: ExtensionContext, model: Model<any>): Promise<ResolvedAuth> {
80
+ const registry = ctx.modelRegistry as any;
81
+
82
+ // Try new API first (pi >= 0.63.0)
83
+ if (typeof registry.getApiKeyAndHeaders === 'function') {
84
+ return await registry.getApiKeyAndHeaders(model);
85
+ }
86
+
87
+ // Fallback to old API (pi < 0.63.0)
88
+ if (typeof registry.getApiKey === 'function') {
89
+ const apiKey = await registry.getApiKey(model);
90
+ if (apiKey === undefined || apiKey === null) {
91
+ return { ok: false, error: "No API key configured for model" };
92
+ }
93
+ return { ok: true, apiKey };
94
+ }
95
+
96
+ return { ok: false, error: "Model registry does not support API key retrieval" };
97
+ }
98
+
69
99
  // --- Streaming API Call ---
70
100
 
71
101
  export interface StreamResult {
@@ -78,31 +108,47 @@ export async function callApiStream(
78
108
  ctx: ExtensionContext,
79
109
  model: Model<any>,
80
110
  body: any,
81
- onUpdate?: AgentToolUpdateCallback
111
+ onUpdate?: AgentToolUpdateCallback,
112
+ signal?: AbortSignal
82
113
  ): Promise<StreamResult> {
83
114
  const config = getConfig(model);
84
- const apiKey = await ctx.modelRegistry.getApiKey(model) || "";
115
+ const auth = await getAuth(ctx, model);
116
+ if (!auth.ok) {
117
+ throw new Error(auth.error || "Failed to get API key and headers");
118
+ }
85
119
 
120
+ // Extract projectId from apiKey for internal Google APIs (gemini-cli, antigravity)
121
+ // These providers return apiKey as JSON: {projectId, token}
86
122
  let projectId: string | undefined;
87
- if (model.api !== "google-generative-ai") {
88
- const parsed = JSON.parse(apiKey);
89
- projectId = parsed.projectId;
123
+ if (model.api !== "google-generative-ai" && auth.apiKey) {
124
+ try {
125
+ const parsed = JSON.parse(auth.apiKey);
126
+ projectId = parsed.projectId;
127
+ } catch {
128
+ // Not a JSON string, ignore
129
+ }
90
130
  }
91
131
 
92
132
  const req = config.buildRequest(model, body, projectId);
93
133
 
94
134
  // Handle auth
95
- if (model.api === "google-generative-ai") {
96
- req.headers["x-goog-api-key"] = apiKey;
97
- } else {
98
- const parsed = JSON.parse(apiKey);
99
- req.headers["Authorization"] = `Bearer ${parsed.token}`;
135
+ if (auth.headers) {
136
+ Object.assign(req.headers, auth.headers);
137
+ }
138
+ if (auth.apiKey) {
139
+ if (model.api === "google-generative-ai") {
140
+ req.headers["x-goog-api-key"] = auth.apiKey;
141
+ } else {
142
+ const parsed = JSON.parse(auth.apiKey);
143
+ req.headers["Authorization"] = `Bearer ${parsed.token}`;
144
+ }
100
145
  }
101
146
 
102
147
  const response = await fetch(req.url, {
103
148
  method: "POST",
104
149
  headers: req.headers,
105
- body: JSON.stringify(req.body)
150
+ body: JSON.stringify(req.body),
151
+ signal
106
152
  });
107
153
 
108
154
  if (!response.ok) {
@@ -120,8 +166,14 @@ export async function callApiStream(
120
166
  let accumulatedText = "";
121
167
  let groundingMetadata: any;
122
168
  let urlContextMetadata: any;
169
+ let currentEventData = "";
170
+ let currentEventName = "";
123
171
 
124
172
  while (true) {
173
+ if (signal?.aborted) {
174
+ throw new Error("Request was aborted");
175
+ }
176
+
125
177
  const { done, value } = await reader.read();
126
178
  if (done) break;
127
179
 
@@ -130,41 +182,70 @@ export async function callApiStream(
130
182
  buffer = lines.pop() || "";
131
183
 
132
184
  for (const line of lines) {
133
- if (!line.startsWith("data:")) continue;
134
- const jsonStr = line.slice(5).trim();
135
- if (!jsonStr) continue;
136
-
137
- let chunk: any;
138
- try {
139
- chunk = JSON.parse(jsonStr);
140
- } catch {
141
- continue;
142
- }
185
+ // Empty line indicates end of an event
186
+ if (line === "" || line === "\r") {
187
+ if (currentEventData) {
188
+ // Process the complete event
189
+ let chunk: any;
190
+ try {
191
+ chunk = JSON.parse(currentEventData);
192
+ } catch {
193
+ currentEventData = "";
194
+ currentEventName = "";
195
+ continue;
196
+ }
143
197
 
144
- // Unwrap response for internal APIs
145
- const data = chunk.response || chunk;
146
- const candidate = data.candidates?.[0];
147
-
148
- if (candidate?.content?.parts) {
149
- for (const part of candidate.content.parts) {
150
- if (part.text) {
151
- accumulatedText += part.text;
152
- // Stream update
153
- onUpdate?.({
154
- content: [{ type: "text", text: accumulatedText }],
155
- details: { streaming: true }
156
- });
198
+ // Check for error in chunk (API errors are sent via SSE stream)
199
+ if (chunk.error) {
200
+ const errorMsg = chunk.error.message || JSON.stringify(chunk.error);
201
+ throw new Error(`API error (${chunk.error.code || chunk.error.status || 'unknown'}): ${errorMsg}`);
202
+ }
203
+
204
+ // Unwrap response for internal APIs
205
+ const data = chunk.response || chunk;
206
+ const candidate = data.candidates?.[0];
207
+
208
+ if (candidate?.content?.parts) {
209
+ for (const part of candidate.content.parts) {
210
+ if (part.text) {
211
+ accumulatedText += part.text;
212
+ // Stream update
213
+ onUpdate?.({
214
+ content: [{ type: "text", text: accumulatedText }],
215
+ details: { streaming: true }
216
+ });
217
+ }
218
+ }
219
+ }
220
+
221
+ // Capture metadata from final chunk
222
+ if (candidate?.groundingMetadata) {
223
+ groundingMetadata = candidate.groundingMetadata;
224
+ }
225
+ // Handle both camelCase and snake_case
226
+ if (candidate?.urlContextMetadata || candidate?.url_context_metadata) {
227
+ urlContextMetadata = candidate.urlContextMetadata || candidate.url_context_metadata;
157
228
  }
158
229
  }
230
+ currentEventData = "";
231
+ currentEventName = "";
232
+ continue;
159
233
  }
160
234
 
161
- // Capture metadata from final chunk
162
- if (candidate?.groundingMetadata) {
163
- groundingMetadata = candidate.groundingMetadata;
164
- }
165
- // Handle both camelCase and snake_case
166
- if (candidate?.urlContextMetadata || candidate?.url_context_metadata) {
167
- urlContextMetadata = candidate.urlContextMetadata || candidate.url_context_metadata;
235
+ // Parse SSE field
236
+ if (line.startsWith("data:")) {
237
+ const data = line.slice(5).trim();
238
+ currentEventData = currentEventData ? currentEventData + "\n" + data : data;
239
+ } else if (line.startsWith("event:")) {
240
+ currentEventName = line.slice(6).trim();
241
+ // Check for error event type
242
+ if (currentEventName === "error") {
243
+ // Next data line should contain error details
244
+ }
245
+ } else if (line.startsWith("id:")) {
246
+ // Event ID, can be ignored for now
247
+ } else if (line.startsWith(":")) {
248
+ // Comment line, ignore
168
249
  }
169
250
  }
170
251
  }
@@ -84,7 +84,7 @@ export async function urlContext(
84
84
  const result = await callApiStream(ctx, model, {
85
85
  contents,
86
86
  tools
87
- }, onUpdate);
87
+ }, onUpdate, signal);
88
88
 
89
89
  const { text, sources } = applyCitations(result.text, result.groundingMetadata);
90
90
 
package/src/web_search.ts CHANGED
@@ -51,7 +51,7 @@ export async function webSearch(
51
51
  const result = await callApiStream(ctx, model, {
52
52
  contents: [{ role: "user", parts: [{ text: prompt }] }],
53
53
  tools
54
- }, onUpdate);
54
+ }, onUpdate, signal);
55
55
 
56
56
  const { text, sources } = applyCitations(result.text, result.groundingMetadata);
57
57