@oh-my-pi/pi-coding-agent 17.2.12 → 17.2.13

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 (134) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-d8xh7keh.md} +57 -0
  3. package/dist/cli.js +3069 -3047
  4. package/dist/types/advisor/delta-split.d.ts +24 -0
  5. package/dist/types/advisor/runtime.d.ts +2 -2
  6. package/dist/types/async/job-manager.d.ts +8 -1
  7. package/dist/types/cli/update-cli.d.ts +53 -1
  8. package/dist/types/config/keybindings.d.ts +10 -0
  9. package/dist/types/config/model-resolver.d.ts +15 -2
  10. package/dist/types/config/settings-schema.d.ts +4 -0
  11. package/dist/types/discovery/agents-md.d.ts +10 -1
  12. package/dist/types/eval/runner-cache.d.ts +12 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +12 -3
  14. package/dist/types/extensibility/extensions/types.d.ts +15 -4
  15. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +4 -1
  16. package/dist/types/lib/xai-http.d.ts +0 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +8 -5
  18. package/dist/types/modes/components/agent-hub-renderer.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/types.d.ts +4 -0
  20. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -4
  21. package/dist/types/modes/interactive-mode.d.ts +17 -8
  22. package/dist/types/modes/types.d.ts +7 -10
  23. package/dist/types/modes/utils/hotkeys-markdown.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +2 -0
  25. package/dist/types/session/agent-session.d.ts +22 -3
  26. package/dist/types/session/messages.d.ts +20 -0
  27. package/dist/types/session/retry-fallback-chains.d.ts +13 -0
  28. package/dist/types/session/session-advisors.d.ts +1 -1
  29. package/dist/types/session/session-history-format.d.ts +10 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -1
  31. package/dist/types/session/session-tools.d.ts +24 -5
  32. package/dist/types/session/turn-recovery.d.ts +34 -5
  33. package/dist/types/slash-commands/types.d.ts +5 -1
  34. package/dist/types/task/executor.d.ts +1 -1
  35. package/dist/types/tools/approval.d.ts +7 -0
  36. package/dist/types/tools/todo.d.ts +14 -15
  37. package/dist/types/tools/write.d.ts +2 -2
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/dist/types/vibe/runtime.d.ts +1 -1
  40. package/dist/types/web/parallel.d.ts +1 -0
  41. package/dist/types/web/search/providers/brave.d.ts +8 -3
  42. package/dist/types/web/search/providers/codex.d.ts +6 -0
  43. package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
  44. package/dist/types/web/search/providers/jina.d.ts +3 -3
  45. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  46. package/dist/types/web/search/providers/perplexity.d.ts +4 -0
  47. package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
  48. package/package.json +13 -13
  49. package/src/advisor/delta-split.ts +98 -0
  50. package/src/advisor/runtime.ts +321 -69
  51. package/src/async/job-manager.ts +14 -3
  52. package/src/cli/plugin-cli.ts +30 -2
  53. package/src/cli/update-cli.ts +259 -24
  54. package/src/config/keybindings.ts +52 -9
  55. package/src/config/model-resolver.ts +19 -3
  56. package/src/config/settings-schema.ts +5 -0
  57. package/src/cursor.ts +10 -5
  58. package/src/discovery/agents-md.ts +61 -23
  59. package/src/eval/jl/kernel.ts +2 -20
  60. package/src/eval/py/kernel.ts +2 -20
  61. package/src/eval/rb/kernel.ts +2 -20
  62. package/src/eval/runner-cache.ts +41 -0
  63. package/src/exec/non-interactive-env.ts +14 -3
  64. package/src/extensibility/extensions/loader.ts +5 -2
  65. package/src/extensibility/extensions/runner.ts +184 -66
  66. package/src/extensibility/extensions/types.ts +26 -2
  67. package/src/extensibility/extensions/wrapper.ts +13 -7
  68. package/src/extensibility/plugins/marketplace/manager.ts +6 -2
  69. package/src/hindsight/client.ts +1 -1
  70. package/src/lib/xai-http.ts +0 -4
  71. package/src/lsp/client.ts +2 -0
  72. package/src/lsp/servers.ts +1 -1
  73. package/src/mcp/tool-bridge.ts +15 -6
  74. package/src/modes/components/agent-hub-renderer.ts +9 -3
  75. package/src/modes/components/agent-hub.ts +2 -1
  76. package/src/modes/components/status-line/component.ts +58 -6
  77. package/src/modes/components/status-line/segments.ts +12 -1
  78. package/src/modes/components/status-line/types.ts +1 -0
  79. package/src/modes/components/user-message.ts +20 -5
  80. package/src/modes/controllers/event-controller.ts +48 -0
  81. package/src/modes/controllers/extension-ui-controller.ts +14 -7
  82. package/src/modes/controllers/input-controller.ts +25 -6
  83. package/src/modes/interactive-mode.ts +315 -129
  84. package/src/modes/rpc/rpc-frame.ts +13 -5
  85. package/src/modes/theme/tui-adapters.ts +4 -5
  86. package/src/modes/types.ts +13 -7
  87. package/src/modes/utils/hotkeys-markdown.ts +10 -6
  88. package/src/prompts/system/system-prompt.md +1 -1
  89. package/src/registry/persisted-agents.ts +43 -8
  90. package/src/sdk.ts +139 -8
  91. package/src/session/agent-session-types.ts +2 -0
  92. package/src/session/agent-session.ts +66 -10
  93. package/src/session/messages.ts +98 -28
  94. package/src/session/retry-fallback-chains.ts +14 -0
  95. package/src/session/session-advisors.ts +32 -15
  96. package/src/session/session-history-format.ts +15 -1
  97. package/src/session/session-maintenance.ts +8 -8
  98. package/src/session/session-manager.ts +6 -2
  99. package/src/session/session-tools.ts +321 -184
  100. package/src/session/turn-recovery.ts +225 -47
  101. package/src/slash-commands/builtin-modes.ts +41 -12
  102. package/src/slash-commands/types.ts +5 -1
  103. package/src/task/executor.ts +92 -45
  104. package/src/task/structured-subagent.ts +5 -5
  105. package/src/tools/approval.ts +44 -10
  106. package/src/tools/fetch.ts +21 -2
  107. package/src/tools/image-gen.ts +6 -8
  108. package/src/tools/todo.ts +70 -26
  109. package/src/tools/tts.ts +3 -2
  110. package/src/tools/write.ts +7 -3
  111. package/src/utils/local-date.ts +13 -0
  112. package/src/utils/tools-manager.ts +2 -2
  113. package/src/vibe/runtime.ts +22 -14
  114. package/src/web/kagi.ts +91 -34
  115. package/src/web/parallel.ts +11 -2
  116. package/src/web/scrapers/crates-io.ts +2 -2
  117. package/src/web/scrapers/discogs.ts +2 -2
  118. package/src/web/scrapers/docs-rs.ts +2 -2
  119. package/src/web/scrapers/github.ts +2 -2
  120. package/src/web/scrapers/musicbrainz.ts +1 -2
  121. package/src/web/scrapers/pubmed.ts +2 -2
  122. package/src/web/scrapers/sec-edgar.ts +2 -2
  123. package/src/web/search/providers/brave.ts +121 -46
  124. package/src/web/search/providers/codex.ts +88 -12
  125. package/src/web/search/providers/exa.ts +45 -10
  126. package/src/web/search/providers/firecrawl.ts +53 -11
  127. package/src/web/search/providers/gemini.ts +139 -27
  128. package/src/web/search/providers/jina.ts +48 -25
  129. package/src/web/search/providers/parallel.ts +23 -9
  130. package/src/web/search/providers/perplexity.ts +24 -7
  131. package/src/web/search/providers/searxng.ts +77 -1
  132. package/src/web/search/providers/tavily.ts +23 -22
  133. package/src/web/search/providers/tinyfish.ts +44 -10
  134. package/src/web/search/providers/xai.ts +85 -14
@@ -14,7 +14,7 @@ import {
14
14
  getAntigravityUserAgent,
15
15
  getGeminiCliHeaders,
16
16
  } from "@oh-my-pi/pi-catalog/wire/gemini-headers";
17
- import { fetchWithRetry } from "@oh-my-pi/pi-utils";
17
+ import { fetchWithRetry, USER_AGENT } from "@oh-my-pi/pi-utils";
18
18
 
19
19
  import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
20
20
  import { SearchProviderError } from "../../../web/search/types";
@@ -25,7 +25,9 @@ import { classifyProviderHttpError, withHardTimeout } from "./utils";
25
25
 
26
26
  const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
27
27
  const DEVELOPER_API_PROVIDER = "google";
28
- const DEVELOPER_API_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta";
28
+ const CLOUDFLARE_GATEWAY_PROVIDER = "cloudflare-ai-gateway";
29
+ const DEFAULT_DEVELOPER_API_HOST = "https://generativelanguage.googleapis.com";
30
+ const DEVELOPER_API_VERSION = "v1beta";
29
31
  const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
30
32
  const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
31
33
  const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT] as const;
@@ -41,6 +43,32 @@ function resolveGeminiSearchModel(configuredModel: string | undefined): string {
41
43
  return model || DEFAULT_MODEL;
42
44
  }
43
45
 
46
+ interface GeminiDeveloperEndpoint {
47
+ url: string;
48
+ authProvider: typeof DEVELOPER_API_PROVIDER | typeof CLOUDFLARE_GATEWAY_PROVIDER;
49
+ isCloudflareGateway: boolean;
50
+ }
51
+
52
+ function resolveGeminiDeveloperEndpoint(): GeminiDeveloperEndpoint {
53
+ const configuredHost = Bun.env.GOOGLE_GEMINI_BASE_URL?.trim().replace(/\/+$/, "");
54
+ const host = configuredHost || DEFAULT_DEVELOPER_API_HOST;
55
+ let parsed: URL;
56
+ try {
57
+ parsed = new URL(host);
58
+ } catch {
59
+ throw new SearchProviderError("gemini", "GOOGLE_GEMINI_BASE_URL must be a valid absolute URL", 400);
60
+ }
61
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
62
+ throw new SearchProviderError("gemini", "GOOGLE_GEMINI_BASE_URL must use HTTP or HTTPS", 400);
63
+ }
64
+ const isCloudflareGateway = parsed.hostname === "gateway.ai.cloudflare.com";
65
+ return {
66
+ url: `${host}/${DEVELOPER_API_VERSION}`,
67
+ authProvider: isCloudflareGateway ? CLOUDFLARE_GATEWAY_PROVIDER : DEVELOPER_API_PROVIDER,
68
+ isCloudflareGateway,
69
+ };
70
+ }
71
+
44
72
  const GEMINI_PROVIDERS = ["google-gemini-cli", "google-antigravity"] as const;
45
73
  type GeminiProviderId = (typeof GEMINI_PROVIDERS)[number];
46
74
 
@@ -292,6 +320,79 @@ async function parseGeminiSearchStream(
292
320
  };
293
321
  }
294
322
 
323
+ function isGroundingRedirectUrl(url: string): boolean {
324
+ try {
325
+ const parsed = new URL(url);
326
+ return (
327
+ parsed.hostname === "vertexaisearch.cloud.google.com" && parsed.pathname.includes("/grounding-api-redirect")
328
+ );
329
+ } catch {
330
+ return false;
331
+ }
332
+ }
333
+
334
+ async function resolveGroundingRedirect(
335
+ proxyUrl: string,
336
+ fetchImpl: FetchImpl | undefined,
337
+ signal: AbortSignal | undefined,
338
+ ): Promise<string> {
339
+ try {
340
+ const response = await (fetchImpl ?? fetch)(proxyUrl, {
341
+ method: "HEAD",
342
+ redirect: "manual",
343
+ signal: withHardTimeout(signal, 5000),
344
+ });
345
+ const location = response.headers.get("location");
346
+ if (!location) return proxyUrl;
347
+ const resolved = new URL(location, proxyUrl);
348
+ return resolved.protocol === "http:" || resolved.protocol === "https:" ? resolved.toString() : proxyUrl;
349
+ } catch {
350
+ return proxyUrl;
351
+ }
352
+ }
353
+
354
+ async function finalizeGeminiSearchResult(
355
+ result: GeminiSearchResult,
356
+ fetchImpl: FetchImpl | undefined,
357
+ signal: AbortSignal | undefined,
358
+ ): Promise<GeminiSearchResult> {
359
+ if (!result.answer && result.sources.length === 0) {
360
+ throw new SearchProviderError("gemini", "Gemini API returned an empty grounded response", 502);
361
+ }
362
+
363
+ const redirectUrls = new Set<string>();
364
+ for (const source of result.sources) {
365
+ if (isGroundingRedirectUrl(source.url)) redirectUrls.add(source.url);
366
+ }
367
+ for (const citation of result.citations) {
368
+ if (isGroundingRedirectUrl(citation.url)) redirectUrls.add(citation.url);
369
+ }
370
+ if (redirectUrls.size === 0) return result;
371
+
372
+ signal?.throwIfAborted();
373
+ const resolvedEntries = await Promise.all(
374
+ [...redirectUrls].map(async url => [url, await resolveGroundingRedirect(url, fetchImpl, signal)] as const),
375
+ );
376
+ signal?.throwIfAborted();
377
+ const resolvedUrls = new Map(resolvedEntries);
378
+ for (const source of result.sources) {
379
+ source.url = resolvedUrls.get(source.url) ?? source.url;
380
+ }
381
+ for (const citation of result.citations) {
382
+ citation.url = resolvedUrls.get(citation.url) ?? citation.url;
383
+ }
384
+
385
+ const seenUrls = new Set<string>();
386
+ let writeIndex = 0;
387
+ for (const source of result.sources) {
388
+ if (seenUrls.has(source.url)) continue;
389
+ seenUrls.add(source.url);
390
+ result.sources[writeIndex++] = source;
391
+ }
392
+ result.sources.length = writeIndex;
393
+ return result;
394
+ }
395
+
295
396
  /**
296
397
  * Calls the Cloud Code Assist API with Google Search grounding enabled.
297
398
  *
@@ -335,8 +436,8 @@ async function callGeminiSearch(
335
436
  requestId: `agent-${crypto.randomUUID()}`,
336
437
  }
337
438
  : {
338
- userAgent: "pi-coding-agent",
339
- requestId: `pi-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
439
+ userAgent: USER_AGENT,
440
+ requestId: `omp-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
340
441
  };
341
442
 
342
443
  const normalizedSystemPrompt = systemPrompt?.toWellFormed();
@@ -420,7 +521,8 @@ async function callGeminiSearch(
420
521
  }
421
522
 
422
523
  if (!response?.ok) {
423
- const errorText = response ? await response.text() : "Network error";
524
+ const rawErrorText = response ? await response.text() : "Network error";
525
+ const errorText = auth.accessToken ? rawErrorText.split(auth.accessToken).join("[redacted]") : rawErrorText;
424
526
  const status = response?.status ?? 502;
425
527
  const classified = classifyProviderHttpError("gemini", status, errorText);
426
528
  if (classified) throw classified;
@@ -431,11 +533,12 @@ async function callGeminiSearch(
431
533
  throw new SearchProviderError("gemini", "Gemini API returned no response body", 500);
432
534
  }
433
535
 
434
- return parseGeminiSearchStream(response.body, model);
536
+ return finalizeGeminiSearchResult(await parseGeminiSearchStream(response.body, model), fetchImpl, signal);
435
537
  }
436
538
 
437
539
  async function callGeminiDeveloperSearch(
438
540
  apiKey: string,
541
+ endpoint: GeminiDeveloperEndpoint,
439
542
  model: string,
440
543
  query: string,
441
544
  systemPrompt: string | undefined,
@@ -473,26 +576,26 @@ async function callGeminiDeveloperSearch(
473
576
  requestBody.generationConfig = generationConfig;
474
577
  }
475
578
 
476
- const response = await fetchWithRetry(
477
- () => `${DEVELOPER_API_ENDPOINT}/models/${model}:streamGenerateContent?alt=sse`,
478
- {
479
- method: "POST",
480
- headers: {
481
- "x-goog-api-key": apiKey,
482
- "Content-Type": "application/json",
483
- Accept: "text/event-stream",
484
- },
485
- body: JSON.stringify(requestBody),
486
- signal: withHardTimeout(signal, timeoutMs),
487
- fetch: fetchImpl,
488
- maxAttempts: MAX_RETRIES + 1,
489
- defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
490
- maxDelayMs: RATE_LIMIT_BUDGET_MS,
579
+ const response = await fetchWithRetry(() => `${endpoint.url}/models/${model}:streamGenerateContent?alt=sse`, {
580
+ method: "POST",
581
+ headers: {
582
+ ...(endpoint.isCloudflareGateway
583
+ ? { "cf-aig-authorization": `Bearer ${apiKey}` }
584
+ : { "x-goog-api-key": apiKey }),
585
+ "Content-Type": "application/json",
586
+ Accept: "text/event-stream",
491
587
  },
492
- );
588
+ body: JSON.stringify(requestBody),
589
+ signal: withHardTimeout(signal, timeoutMs),
590
+ fetch: fetchImpl,
591
+ maxAttempts: MAX_RETRIES + 1,
592
+ defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
593
+ maxDelayMs: RATE_LIMIT_BUDGET_MS,
594
+ });
493
595
 
494
596
  if (!response.ok) {
495
- const errorText = await response.text();
597
+ const rawErrorText = await response.text();
598
+ const errorText = apiKey ? rawErrorText.split(apiKey).join("[redacted]") : rawErrorText;
496
599
  const classified = classifyProviderHttpError("gemini", response.status, errorText);
497
600
  if (classified) throw classified;
498
601
  throw new SearchProviderError(
@@ -506,7 +609,7 @@ async function callGeminiDeveloperSearch(
506
609
  throw new SearchProviderError("gemini", "Gemini API returned no response body", 500);
507
610
  }
508
611
 
509
- return parseGeminiSearchStream(response.body, model);
612
+ return finalizeGeminiSearchResult(await parseGeminiSearchStream(response.body, model), fetchImpl, signal);
510
613
  }
511
614
 
512
615
  /**
@@ -557,16 +660,20 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
557
660
  { sessionId: params.sessionId, signal: params.signal, seed: seed.access },
558
661
  );
559
662
  } else {
560
- const apiKey = await params.authStorage.getApiKey(DEVELOPER_API_PROVIDER, params.sessionId, {
663
+ const endpoint = resolveGeminiDeveloperEndpoint();
664
+ const apiKey = await params.authStorage.getApiKey(endpoint.authProvider, params.sessionId, {
561
665
  signal: params.signal,
562
666
  });
563
667
  if (!apiKey) {
564
668
  throw new Error(
565
- "No Gemini credentials found. Set GEMINI_API_KEY, configure an API key for provider \"google\", or login with 'omp /login google-gemini-cli' / 'omp /login google-antigravity' to enable Gemini web search.",
669
+ endpoint.isCloudflareGateway
670
+ ? 'No Cloudflare AI Gateway credential found. Configure provider "cloudflare-ai-gateway" or set CLOUDFLARE_AI_GATEWAY_API_KEY.'
671
+ : "No Gemini credentials found. Set GEMINI_API_KEY, configure an API key for provider \"google\", or login with 'omp /login google-gemini-cli' / 'omp /login google-antigravity' to enable Gemini web search.",
566
672
  );
567
673
  }
568
674
  result = await callGeminiDeveloperSearch(
569
675
  apiKey,
676
+ endpoint,
570
677
  selectedModel,
571
678
  searchQuery,
572
679
  params.system_prompt,
@@ -609,7 +716,12 @@ export class GeminiProvider extends SearchProvider {
609
716
  // Cheap, in-memory check — avoids driving the refresh pipeline during
610
717
  // the provider-chain probe. `searchGemini` refreshes OAuth lazily on the
611
718
  // actual request and resolves developer API keys through AuthStorage.
612
- return hasGeminiOAuth(authStorage) || authStorage.hasAuth(DEVELOPER_API_PROVIDER);
719
+ if (hasGeminiOAuth(authStorage)) return true;
720
+ try {
721
+ return authStorage.hasAuth(resolveGeminiDeveloperEndpoint().authProvider);
722
+ } catch {
723
+ return false;
724
+ }
613
725
  }
614
726
 
615
727
  search(params: SearchParams): Promise<SearchResponse> {
@@ -5,19 +5,24 @@
5
5
  * cleaned content.
6
6
  */
7
7
 
8
- import { type AuthStorage, type FetchImpl, getEnvApiKey } from "@oh-my-pi/pi-ai";
8
+ import { type ApiKey, type AuthStorage, type FetchImpl, withAuth } from "@oh-my-pi/pi-ai";
9
9
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
10
10
  import { SearchProviderError } from "../../../web/search/types";
11
11
  import { formatQuery, parseSearchQuery } from "../query";
12
+ import { clampNumResults } from "../utils";
12
13
  import type { SearchParams } from "./base";
13
14
  import { SearchProvider } from "./base";
14
15
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
15
16
 
16
17
  const JINA_SEARCH_URL = "https://s.jina.ai";
18
+ const DEFAULT_NUM_RESULTS = 5;
19
+ const MAX_NUM_RESULTS = 20;
17
20
  type SearchParamsWithFetch = SearchParams & { fetch?: FetchImpl };
18
21
 
19
22
  export interface JinaSearchParams {
20
23
  query: string;
24
+ authStorage: AuthStorage;
25
+ sessionId?: string;
21
26
  num_results?: number;
22
27
  /** Single bare host for Jina's `X-Site` in-site search header. */
23
28
  site?: string;
@@ -29,31 +34,37 @@ export interface JinaSearchParams {
29
34
  interface JinaSearchResult {
30
35
  title?: string | null;
31
36
  url?: string | null;
37
+ description?: string | null;
32
38
  content?: string | null;
33
39
  }
34
40
 
35
- type JinaSearchResponse = JinaSearchResult[];
36
-
37
- /** Find JINA_API_KEY from environment or .env files. */
38
- export function findApiKey(): string | null {
39
- return getEnvApiKey("jina") ?? null;
41
+ interface JinaSearchEnvelope {
42
+ code?: unknown;
43
+ data?: unknown;
40
44
  }
41
45
 
46
+ type JinaSearchResponse = JinaSearchResult[];
47
+
42
48
  /** Call Jina Reader search API. */
43
49
  async function callJinaSearch(
44
50
  apiKey: string,
45
51
  query: string,
52
+ numResults: number,
46
53
  site?: string,
47
54
  signal?: AbortSignal,
48
55
  fetchImpl: FetchImpl = fetch,
49
56
  timeoutMs?: number,
50
57
  ): Promise<JinaSearchResponse> {
51
- const requestUrl = `${JINA_SEARCH_URL}/${encodeURIComponent(query)}`;
58
+ const requestUrl = new URL(`${JINA_SEARCH_URL}/${encodeURIComponent(query)}`);
59
+ requestUrl.searchParams.set("count", String(numResults));
60
+
52
61
  const headers: Record<string, string> = {
53
62
  Accept: "application/json",
54
63
  Authorization: `Bearer ${apiKey}`,
55
64
  };
56
65
  if (site) headers["X-Site"] = site;
66
+ headers["X-Respond-With"] = "no-content";
67
+ headers["X-Retain-Images"] = "none";
57
68
  const response = await fetchImpl(requestUrl, {
58
69
  headers,
59
70
  signal: withHardTimeout(signal, timeoutMs),
@@ -66,24 +77,34 @@ async function callJinaSearch(
66
77
  throw new SearchProviderError("jina", `Jina API error (${response.status}): ${errorText}`, response.status);
67
78
  }
68
79
 
69
- const payload = (await response.json()) as { data?: JinaSearchResponse } | null;
70
- return Array.isArray(payload?.data) ? payload.data : [];
80
+ const payload = (await response.json()) as JinaSearchEnvelope | JinaSearchResponse | null;
81
+ if (Array.isArray(payload)) return payload;
82
+ if (!payload || typeof payload !== "object") {
83
+ throw new SearchProviderError("jina", "Jina API returned invalid response: expected an object or array");
84
+ }
85
+ if (typeof payload.code === "number" && payload.code !== 200) {
86
+ throw new SearchProviderError("jina", `Jina API response reported failure (${payload.code})`, payload.code);
87
+ }
88
+ if (!Array.isArray(payload.data)) {
89
+ throw new SearchProviderError("jina", "Jina API returned invalid response: expected data array");
90
+ }
91
+ return payload.data as JinaSearchResponse;
71
92
  }
72
93
 
73
94
  /** Execute Jina web search. */
74
95
  export async function searchJina(params: JinaSearchParams): Promise<SearchResponse> {
75
- const apiKey = findApiKey();
76
- if (!apiKey) {
77
- throw new Error("JINA_API_KEY not found. Set it in environment or .env file.");
78
- }
79
-
80
- const response = await callJinaSearch(
81
- apiKey,
82
- params.query,
83
- params.site,
84
- params.signal,
85
- params.fetch,
86
- params.timeoutMs,
96
+ const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
97
+ const keyOrResolver: ApiKey = params.authStorage.resolver("jina", {
98
+ sessionId: params.sessionId,
99
+ });
100
+ const response = await withAuth(
101
+ keyOrResolver,
102
+ apiKey =>
103
+ callJinaSearch(apiKey, params.query, numResults, params.site, params.signal, params.fetch, params.timeoutMs),
104
+ {
105
+ signal: params.signal,
106
+ missingKeyMessage: 'Jina credentials not found. Set JINA_API_KEY or configure an API key for provider "jina".',
107
+ },
87
108
  );
88
109
  const sources: SearchSource[] = [];
89
110
 
@@ -92,11 +113,11 @@ export async function searchJina(params: JinaSearchParams): Promise<SearchRespon
92
113
  sources.push({
93
114
  title: result.title ?? result.url,
94
115
  url: result.url,
95
- snippet: result.content ?? undefined,
116
+ snippet: result.description?.trim() || result.content?.trim() || undefined,
96
117
  });
97
118
  }
98
119
 
99
- const limitedSources = params.num_results ? sources.slice(0, params.num_results) : sources;
120
+ const limitedSources = sources.slice(0, numResults);
100
121
 
101
122
  return {
102
123
  provider: "jina",
@@ -109,8 +130,8 @@ export class JinaProvider extends SearchProvider {
109
130
  readonly id = "jina";
110
131
  readonly label = "Jina";
111
132
 
112
- isAvailable(_authStorage: AuthStorage): boolean {
113
- return !!findApiKey();
133
+ isAvailable(authStorage: AuthStorage): boolean {
134
+ return authStorage.hasAuth("jina");
114
135
  }
115
136
 
116
137
  search(params: SearchParamsWithFetch): Promise<SearchResponse> {
@@ -134,6 +155,8 @@ export class JinaProvider extends SearchProvider {
134
155
 
135
156
  return searchJina({
136
157
  query,
158
+ authStorage: params.authStorage,
159
+ sessionId: params.sessionId,
137
160
  num_results: params.numSearchResults ?? params.limit,
138
161
  site,
139
162
  signal: params.signal,
@@ -7,6 +7,7 @@ import {
7
7
  ParallelApiError,
8
8
  type ParallelSearchResult,
9
9
  parseParallelErrorResponse,
10
+ parseParallelJsonResponse,
10
11
  parseParallelSearchPayload,
11
12
  } from "../../parallel";
12
13
  import { formatQuery, parseSearchQuery, type StructuredQuery } from "../query";
@@ -28,6 +29,13 @@ interface ParallelSourcePolicy {
28
29
  after_date?: string;
29
30
  }
30
31
 
32
+ const RECENCY_DAYS: Record<NonNullable<SearchParams["recency"]>, number> = {
33
+ day: 1,
34
+ week: 7,
35
+ month: 30,
36
+ year: 365,
37
+ };
38
+
31
39
  /** Site values may carry paths (`github.com/anthropics`); Parallel takes bare hosts. */
32
40
  function toHosts(sites: readonly string[]): string[] {
33
41
  const hosts = new Set<string>();
@@ -39,19 +47,23 @@ function toHosts(sites: readonly string[]): string[] {
39
47
  }
40
48
 
41
49
  /**
42
- * Map parsed `site:`/`-site:`/`after:` directives onto Parallel's
43
- * `source_policy`. Per Parallel docs, `exclude_domains` is ignored when
44
- * `include_domains` is set, so exclusions are only sent without an allow
45
- * list (the central lenient filter enforces them regardless).
50
+ * Map parsed `site:`/`-site:`/`after:` directives and the relative recency
51
+ * option onto Parallel's `source_policy`. An explicit `after:` bound wins.
52
+ * Per Parallel docs, `exclude_domains` is ignored when `include_domains` is
53
+ * set, so exclusions are only sent without an allow list (the central lenient
54
+ * filter enforces them regardless).
46
55
  */
47
- function toSourcePolicy(parsed: StructuredQuery): ParallelSourcePolicy | undefined {
56
+ function toSourcePolicy(parsed: StructuredQuery, recency?: SearchParams["recency"]): ParallelSourcePolicy | undefined {
48
57
  const policy: ParallelSourcePolicy = {};
49
58
  const include = toHosts(parsed.sites);
50
59
  const exclude = toHosts(parsed.excludedSites);
51
60
  if (include.length) policy.include_domains = include;
52
61
  else if (exclude.length) policy.exclude_domains = exclude;
53
62
  if (parsed.after) policy.after_date = parsed.after;
54
- return Object.keys(policy).length ? policy : undefined;
63
+ else if (recency) {
64
+ policy.after_date = new Date(Date.now() - RECENCY_DAYS[recency] * 86_400_000).toISOString().slice(0, 10);
65
+ }
66
+ return policy.include_domains || policy.exclude_domains || policy.after_date ? policy : undefined;
55
67
  }
56
68
 
57
69
  async function searchWithAuthStorage(
@@ -105,7 +117,7 @@ async function searchWithAuthStorage(
105
117
  throw parseParallelErrorResponse(response.status, await response.text());
106
118
  }
107
119
 
108
- const payload: unknown = await response.json();
120
+ const payload = await parseParallelJsonResponse(response, "search");
109
121
  return parseParallelSearchPayload(payload, { parseMetadata: false });
110
122
  },
111
123
  { signal: params.signal },
@@ -116,6 +128,7 @@ export async function searchParallel(
116
128
  params: {
117
129
  query: string;
118
130
  num_results?: number;
131
+ recency?: SearchParams["recency"];
119
132
  signal?: AbortSignal;
120
133
  timeoutMs?: number;
121
134
  fetch?: FetchImpl;
@@ -126,9 +139,9 @@ export async function searchParallel(
126
139
  ): Promise<SearchResponse> {
127
140
  const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
128
141
  const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
129
- // Back-compat: without directives the upstream request is byte-identical.
142
+ // Directives are removed only where Parallel has a native equivalent.
130
143
  const query = parsed.hasDirectives ? formatQuery(parsed, PARALLEL_QUERY_SYNTAX) : params.query;
131
- const sourcePolicy = parsed.hasDirectives ? toSourcePolicy(parsed) : undefined;
144
+ const sourcePolicy = toSourcePolicy(parsed, params.recency);
132
145
 
133
146
  try {
134
147
  const result = await searchWithAuthStorage(
@@ -174,6 +187,7 @@ export class ParallelProvider extends SearchProvider {
174
187
  {
175
188
  query: params.query,
176
189
  num_results: params.numSearchResults ?? params.limit,
190
+ recency: params.recency,
177
191
  signal: params.signal,
178
192
  timeoutMs: params.timeoutMs,
179
193
  fetch: params.fetch,
@@ -150,6 +150,7 @@ interface PerplexityOAuthStreamEvent {
150
150
  error_code?: string;
151
151
  error_message?: string;
152
152
  display_model?: string;
153
+ user_selected_model?: string;
153
154
  uuid?: string;
154
155
  }
155
156
 
@@ -327,7 +328,11 @@ export interface PerplexitySearchParams {
327
328
  system_prompt?: string;
328
329
  /** Pre-parsed view of `query` from the search pipeline; parsed locally when absent. */
329
330
  parsedQuery?: StructuredQuery;
331
+ /** Direct API model. Defaults to `PI_PERPLEXITY_API_MODEL`, then `sonar-pro`. */
332
+ api_model?: string;
330
333
  search_recency_filter?: "hour" | "day" | "week" | "month" | "year";
334
+ /** Consumer subscription model preference. Defaults to `PI_PERPLEXITY_MODEL`, then Sonar (`experimental`). */
335
+ subscription_model?: string;
331
336
  num_results?: number;
332
337
  /** Maximum output tokens. Defaults to 8192. */
333
338
  max_tokens?: number;
@@ -539,6 +544,15 @@ async function callPerplexityApi(
539
544
  return parseStreamedApiResponse(message, metadata);
540
545
  }
541
546
 
547
+ function oauthSourceKey(url: string): string {
548
+ const trimmed = url.trim().replace(/\/$/, "");
549
+ try {
550
+ return new URL(trimmed).href.replace(/\/$/, "");
551
+ } catch {
552
+ return trimmed.toLowerCase();
553
+ }
554
+ }
555
+
542
556
  function buildOAuthSources(event: PerplexityOAuthStreamEvent): SearchSource[] {
543
557
  const results =
544
558
  event.blocks?.find(block => block.intended_usage === "web_results")?.web_result_block?.web_results ?? [];
@@ -607,6 +621,7 @@ async function callPerplexityAsk(
607
621
  params: PerplexitySearchParams,
608
622
  filters: PerplexityNativeFilters,
609
623
  ): Promise<{ answer: string; sources: SearchSource[]; model?: string; requestId?: string }> {
624
+ const subscriptionModel = params.subscription_model?.trim() || $env.PI_PERPLEXITY_MODEL?.trim() || "experimental";
610
625
  const requestId = crypto.randomUUID();
611
626
  // The consumer `perplexity_ask` endpoint is itself a research assistant and
612
627
  // has no system-message slot. Prepending the API-style system prompt to the
@@ -645,14 +660,14 @@ async function callPerplexityAsk(
645
660
  query_str: effectiveQuery,
646
661
  search_focus: "internet",
647
662
  mode: "copilot",
648
- model_preference: "experimental",
663
+ model_preference: subscriptionModel,
649
664
  sources: ["web"],
650
665
  attachments: [],
651
666
  frontend_uuid: crypto.randomUUID(),
652
667
  frontend_context_uuid: crypto.randomUUID(),
653
668
  version: OAUTH_API_VERSION,
654
669
  language: "en-US",
655
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
670
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
656
671
  // Recency cannot be combined with absolute date filters; explicit
657
672
  // before:/after: bounds take precedence.
658
673
  search_recency_filter: filters.afterDate || filters.beforeDate ? null : (params.search_recency_filter ?? null),
@@ -740,12 +755,14 @@ async function callPerplexityAsk(
740
755
  if (eventAnswer.length > 0) {
741
756
  answer = eventAnswer;
742
757
  }
743
-
744
758
  for (const source of buildOAuthSources(mergedEvent)) {
745
- sourcesByUrl.set(source.url, source);
759
+ sourcesByUrl.set(oauthSourceKey(source.url), source);
746
760
  }
747
761
 
748
- if (mergedEvent.display_model) model = mergedEvent.display_model;
762
+ const reportedModel = [mergedEvent.user_selected_model, mergedEvent.display_model].find(
763
+ candidate => candidate && candidate !== "turbo",
764
+ );
765
+ if (reportedModel) model = reportedModel;
749
766
  if (mergedEvent.uuid) finalRequestId = mergedEvent.uuid;
750
767
  if (mergedEvent.final || mergedEvent.status === "COMPLETED") {
751
768
  break;
@@ -755,7 +772,7 @@ async function callPerplexityAsk(
755
772
  return {
756
773
  answer,
757
774
  sources: [...sourcesByUrl.values()],
758
- model,
775
+ model: model ?? (auth.type === "anonymous" ? mergedEvent.display_model : subscriptionModel),
759
776
  requestId: finalRequestId ?? requestId,
760
777
  };
761
778
  }
@@ -872,7 +889,7 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
872
889
  messages.push({ role: "user", content: filters.query });
873
890
 
874
891
  const request: PerplexityRequest = {
875
- model: "sonar-pro",
892
+ model: params.api_model?.trim() || $env.PI_PERPLEXITY_API_MODEL?.trim() || "sonar-pro",
876
893
  messages,
877
894
  max_tokens: params.max_tokens ?? DEFAULT_MAX_TOKENS,
878
895
  temperature: params.temperature ?? DEFAULT_TEMPERATURE,