@oh-my-pi/pi-coding-agent 12.6.0 → 12.7.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,352 @@
1
+ /**
2
+ * Z.AI Web Search Provider
3
+ *
4
+ * Calls Z.AI's remote MCP server (`webSearchPrime`) and adapts results into
5
+ * the unified SearchResponse shape used by the web search tool.
6
+ */
7
+ import { getEnvApiKey } from "@oh-my-pi/pi-ai";
8
+ import { getAgentDbPath } from "@oh-my-pi/pi-utils/dirs";
9
+ import { AgentStorage } from "../../../session/agent-storage";
10
+
11
+ import type { SearchResponse, SearchSource } from "../../../web/search/types";
12
+ import { SearchProviderError } from "../../../web/search/types";
13
+ import type { SearchParams } from "./base";
14
+ import { SearchProvider } from "./base";
15
+
16
+ const ZAI_MCP_URL = "https://api.z.ai/api/mcp/web_search_prime/mcp";
17
+ const ZAI_TOOL_NAME = "webSearchPrime";
18
+ const DEFAULT_NUM_RESULTS = 10;
19
+
20
+ export interface ZaiSearchParams {
21
+ query: string;
22
+ num_results?: number;
23
+ }
24
+
25
+ interface ZaiSearchResult {
26
+ title?: string;
27
+ content?: string;
28
+ link?: string;
29
+ url?: string;
30
+ media?: string;
31
+ publish_date?: string;
32
+ publishedDate?: string;
33
+ }
34
+
35
+ interface ZaiWebSearchResponse {
36
+ id?: string;
37
+ request_id?: string;
38
+ requestId?: string;
39
+ search_result?: ZaiSearchResult[];
40
+ results?: ZaiSearchResult[];
41
+ }
42
+
43
+ interface JsonRpcError {
44
+ code?: number;
45
+ message?: string;
46
+ }
47
+
48
+ interface JsonRpcPayload {
49
+ result?: unknown;
50
+ error?: JsonRpcError;
51
+ }
52
+
53
+ function asRecord(value: unknown): Record<string, unknown> | null {
54
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
55
+ }
56
+
57
+ function asString(value: unknown): string | undefined {
58
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
59
+ }
60
+
61
+ /**
62
+ * Finds Z.AI API credentials from environment or saved auth storage.
63
+ * Priority: ZAI_API_KEY env var, then credentials stored under provider "zai".
64
+ */
65
+ export async function findApiKey(): Promise<string | null> {
66
+ const envKey = getEnvApiKey("zai");
67
+ if (envKey) return envKey;
68
+
69
+ try {
70
+ const storage = await AgentStorage.open(getAgentDbPath());
71
+ const records = storage.listAuthCredentials("zai");
72
+ for (const record of records) {
73
+ const credential = record.credential;
74
+ if (credential.type === "api_key" && credential.key.trim().length > 0) {
75
+ return credential.key;
76
+ }
77
+ if (credential.type === "oauth" && credential.access.trim().length > 0) {
78
+ return credential.access;
79
+ }
80
+ }
81
+ } catch {
82
+ return null;
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ function dateToAgeSeconds(dateStr: string | undefined): number | undefined {
89
+ if (!dateStr) return undefined;
90
+ try {
91
+ const date = new Date(dateStr);
92
+ if (Number.isNaN(date.getTime())) return undefined;
93
+ return Math.floor((Date.now() - date.getTime()) / 1000);
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ async function callZaiTool(apiKey: string, args: Record<string, unknown>): Promise<unknown> {
100
+ const response = await fetch(ZAI_MCP_URL, {
101
+ method: "POST",
102
+ headers: {
103
+ Authorization: `Bearer ${apiKey}`,
104
+ "Content-Type": "application/json",
105
+ Accept: "application/json, text/event-stream",
106
+ },
107
+ body: JSON.stringify({
108
+ jsonrpc: "2.0",
109
+ id: crypto.randomUUID(),
110
+ method: "tools/call",
111
+ params: {
112
+ name: ZAI_TOOL_NAME,
113
+ arguments: args,
114
+ },
115
+ }),
116
+ });
117
+
118
+ if (!response.ok) {
119
+ const errorText = await response.text();
120
+ throw new SearchProviderError("zai", `Z.AI MCP error (${response.status}): ${errorText}`, response.status);
121
+ }
122
+
123
+ const rawText = await response.text();
124
+
125
+ const parsedMessages: unknown[] = [];
126
+ for (const line of rawText.split("\n")) {
127
+ const trimmed = line.trim();
128
+ if (!trimmed.startsWith("data:")) continue;
129
+ const data = trimmed.slice(5).trim();
130
+ if (!data) continue;
131
+ try {
132
+ parsedMessages.push(JSON.parse(data));
133
+ } catch {
134
+ // Ignore non-JSON data events.
135
+ }
136
+ }
137
+
138
+ if (parsedMessages.length === 0) {
139
+ try {
140
+ parsedMessages.push(JSON.parse(rawText));
141
+ } catch {
142
+ throw new SearchProviderError("zai", "Failed to parse Z.AI MCP response", 500);
143
+ }
144
+ }
145
+
146
+ const parsed = parsedMessages[parsedMessages.length - 1];
147
+ const parsedRecord = asRecord(parsed);
148
+ const directErrorCode = typeof parsedRecord?.code === "number" ? parsedRecord.code : undefined;
149
+ const directErrorSuccess = parsedRecord?.success;
150
+ const directErrorMessage =
151
+ asString(parsedRecord?.msg) ?? asString(parsedRecord?.message) ?? asString(parsedRecord?.error_message);
152
+ if (directErrorSuccess === false && directErrorMessage) {
153
+ throw new SearchProviderError(
154
+ "zai",
155
+ `Z.AI API error${directErrorCode ? ` (${directErrorCode})` : ""}: ${directErrorMessage}`,
156
+ directErrorCode,
157
+ );
158
+ }
159
+
160
+ const payload = parsed as JsonRpcPayload;
161
+ if (payload.error) {
162
+ const status = typeof payload.error.code === "number" ? payload.error.code : 400;
163
+ throw new SearchProviderError(
164
+ "zai",
165
+ `Z.AI MCP error${payload.error.code ? ` (${payload.error.code})` : ""}: ${payload.error.message ?? "Unknown error"}`,
166
+ status,
167
+ );
168
+ }
169
+
170
+ const resultRecord = asRecord(payload.result);
171
+ if (resultRecord?.isError === true) {
172
+ const content = Array.isArray(resultRecord.content) ? resultRecord.content : [];
173
+ const errorText = content
174
+ .map(item => asString(asRecord(item)?.text))
175
+ .filter((text): text is string => text !== undefined)
176
+ .join("\n")
177
+ .trim();
178
+ const statusMatch = errorText.match(/MCP error\s*(-?\d+)/i);
179
+ const statusCode = statusMatch ? Math.abs(Number.parseInt(statusMatch[1], 10)) : 400;
180
+ throw new SearchProviderError("zai", errorText || "Z.AI MCP tool call failed", statusCode);
181
+ }
182
+
183
+ if (payload.result !== undefined) {
184
+ return payload.result;
185
+ }
186
+
187
+ return parsed;
188
+ }
189
+
190
+ async function callZaiSearch(apiKey: string, params: ZaiSearchParams): Promise<unknown> {
191
+ const count = params.num_results ?? DEFAULT_NUM_RESULTS;
192
+ const attempts: Record<string, unknown>[] = [
193
+ { query: params.query, count },
194
+ { search_query: params.query, count },
195
+ { search_query: params.query, search_engine: "search-prime", count },
196
+ ];
197
+
198
+ let lastError: unknown;
199
+ for (let i = 0; i < attempts.length; i++) {
200
+ try {
201
+ return await callZaiTool(apiKey, attempts[i]);
202
+ } catch (error) {
203
+ lastError = error;
204
+ const isLastAttempt = i === attempts.length - 1;
205
+ if (isLastAttempt) {
206
+ throw error;
207
+ }
208
+ if (!(error instanceof SearchProviderError)) {
209
+ throw error;
210
+ }
211
+ const message = error.message.toLowerCase();
212
+ const looksLikeArgError =
213
+ error.status === 400 ||
214
+ message.includes("invalid") ||
215
+ message.includes("argument") ||
216
+ message.includes("search_query") ||
217
+ message.includes("query");
218
+ if (!looksLikeArgError) {
219
+ throw error;
220
+ }
221
+ }
222
+ }
223
+
224
+ throw lastError ?? new SearchProviderError("zai", "Z.AI search failed", 500);
225
+ }
226
+
227
+ function getSearchResults(value: unknown): ZaiSearchResult[] {
228
+ if (Array.isArray(value)) {
229
+ return value as ZaiSearchResult[];
230
+ }
231
+ const obj = asRecord(value);
232
+ if (!obj) return [];
233
+
234
+ const searchResult = obj.search_result;
235
+ if (Array.isArray(searchResult)) return searchResult as ZaiSearchResult[];
236
+
237
+ const results = obj.results;
238
+ if (Array.isArray(results)) return results as ZaiSearchResult[];
239
+
240
+ return [];
241
+ }
242
+
243
+ function parseSearchPayload(rawResult: unknown): {
244
+ results: ZaiSearchResult[];
245
+ answer?: string;
246
+ requestId?: string;
247
+ } {
248
+ const candidates: unknown[] = [rawResult];
249
+ const textParts: string[] = [];
250
+
251
+ const root = asRecord(rawResult);
252
+ if (root) {
253
+ if (root.structuredContent) candidates.push(root.structuredContent);
254
+ if (root.data) candidates.push(root.data);
255
+ if (root.result) candidates.push(root.result);
256
+
257
+ const content = root.content;
258
+ if (Array.isArray(content)) {
259
+ for (const part of content) {
260
+ const partObj = asRecord(part);
261
+ const text = asString(partObj?.text);
262
+ if (!text) continue;
263
+ textParts.push(text);
264
+ try {
265
+ candidates.push(JSON.parse(text));
266
+ } catch {
267
+ // Not JSON payload; keep as fallback answer text.
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+ for (const candidate of candidates) {
274
+ const results = getSearchResults(candidate);
275
+ if (results.length > 0) {
276
+ const obj = asRecord(candidate) as ZaiWebSearchResponse | null;
277
+ return {
278
+ results,
279
+ answer: textParts.length > 0 ? textParts.join("\n\n") : undefined,
280
+ requestId: obj?.request_id ?? obj?.requestId ?? obj?.id,
281
+ };
282
+ }
283
+ }
284
+
285
+ return {
286
+ results: [],
287
+ answer: textParts.length > 0 ? textParts.join("\n\n") : undefined,
288
+ };
289
+ }
290
+
291
+ function toSources(results: ZaiSearchResult[]): SearchSource[] {
292
+ const sources: SearchSource[] = [];
293
+ for (const result of results) {
294
+ const url = asString(result.link) ?? asString(result.url);
295
+ if (!url) continue;
296
+
297
+ const publishedDate = asString(result.publish_date) ?? asString(result.publishedDate);
298
+ sources.push({
299
+ title: asString(result.title) ?? url,
300
+ url,
301
+ snippet: asString(result.content),
302
+ publishedDate,
303
+ ageSeconds: dateToAgeSeconds(publishedDate),
304
+ author: asString(result.media),
305
+ });
306
+ }
307
+ return sources;
308
+ }
309
+
310
+ /** Execute Z.AI web search via remote MCP endpoint. */
311
+ export async function searchZai(params: ZaiSearchParams): Promise<SearchResponse> {
312
+ const apiKey = await findApiKey();
313
+ if (!apiKey) {
314
+ throw new Error("Z.AI credentials not found. Set ZAI_API_KEY or login with 'omp /login zai'.");
315
+ }
316
+
317
+ const rawResult = await callZaiSearch(apiKey, params);
318
+ const payload = parseSearchPayload(rawResult);
319
+ let sources = toSources(payload.results);
320
+
321
+ if (params.num_results && sources.length > params.num_results) {
322
+ sources = sources.slice(0, params.num_results);
323
+ }
324
+
325
+ return {
326
+ provider: "zai",
327
+ answer: payload.answer,
328
+ sources,
329
+ requestId: payload.requestId,
330
+ };
331
+ }
332
+
333
+ /** Search provider for Z.AI web search MCP. */
334
+ export class ZaiProvider extends SearchProvider {
335
+ readonly id = "zai";
336
+ readonly label = "Z.AI";
337
+
338
+ async isAvailable(): Promise<boolean> {
339
+ try {
340
+ return !!(await findApiKey());
341
+ } catch {
342
+ return false;
343
+ }
344
+ }
345
+
346
+ search(params: SearchParams): Promise<SearchResponse> {
347
+ return searchZai({
348
+ query: params.query,
349
+ num_results: params.numSearchResults ?? params.limit,
350
+ });
351
+ }
352
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  /** Supported web search providers */
8
- export type SearchProviderId = "exa" | "jina" | "anthropic" | "perplexity" | "gemini" | "codex";
8
+ export type SearchProviderId = "exa" | "jina" | "zai" | "anthropic" | "perplexity" | "gemini" | "codex";
9
9
 
10
10
  /** Source returned by search (all providers) */
11
11
  export interface SearchSource {