pi-web-search 1.6.0 → 1.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-web-search
2
2
 
3
- Provider-native web search for [pi](https://pi.dev) with Gemini + URL Context, xAI Grok, OpenAI Responses variants, Anthropic, and OpenCode Zen/Go.
3
+ Provider-native web search for [pi](https://pi.dev) with Gemini + URL Context, xAI Grok, OpenAI Responses variants, Anthropic, DeepSeek, Ollama Cloud, and OpenCode Zen/Go.
4
4
 
5
5
  ## Tools
6
6
 
@@ -17,17 +17,23 @@ Search the web using your currently selected model. Automatically picks the righ
17
17
  | OpenAI Codex | Codex Responses API web search (`openai-codex-responses`) |
18
18
  | GitHub Copilot | OpenAI Responses API web search via Copilot credentials |
19
19
  | Anthropic | Messages API web search |
20
+ | DeepSeek | Anthropic-compatible Messages API `web_search_20260209` |
21
+ | Ollama Cloud | Ollama web search API (`/api/web_search`, standalone REST) |
20
22
  | OpenCode Zen / Go | Responses API web search (models that use the `openai-responses` API) |
21
23
 
22
24
  GitHub Copilot OpenAI Responses models are supported, including Business and Enterprise seats whose API endpoint is resolved from their authenticated Copilot credentials. This includes models such as `gpt-5.6-sol`.
23
25
 
24
- OpenCode Zen and OpenCode Go Responses models (for example `opencode-go/gpt-5.6-luna` or `opencode-go/grok-4.6`) use the same Responses web search. OpenCode routes traffic per conversation, so `web_search` sends the `x-opencode-session` and `x-opencode-client` headers pi uses, keyed to the active session. Only models exposed through that Responses API are supported: OpenCode `chat/completions` models have no provider-native search tool, and the gateway's Anthropic Messages models are unverified.
26
+ OpenCode Zen and OpenCode Go Responses models (for example `opencode-go/gpt-5.6-luna` or `opencode-go/grok-4.6`) use the same Responses web search. OpenCode routes traffic per conversation, so `web_search` sends the `x-opencode-session` and `x-opencode-client` headers pi uses, keyed to the active session. Only models exposed through that Responses API are supported: OpenCode `chat/completions` models have no provider-native search tool, and the gateway's Anthropic Messages models are unverified. Grok ids such as `opencode-go/grok-4.6` get the Grok request shape described below unless `compat.webSearchDialect` is `"openai"`; the session headers are unchanged.
27
+
28
+ Grok models on OpenAI-compatible Responses gateways use Grok's request shape: no `web_search_call.results` include (Grok rejects it with 400), a message-array input, and inline citations. By default this applies to the native `xai` provider and to any model id starting with `grok-`, `grok.`, or equal to `grok`. Set `"compat": { "webSearchDialect": "grok" }` or `"openai"` in `models.json` to override detection, on the model or on a provider that serves only Grok; model-level `compat` wins over provider-level. Only the request shape changes: auth, error labels, and `providerKind` still follow the provider, and gateway Grok still inherits the session thinking level. Only the native `xai` provider omits `reasoning.effort`.
29
+
30
+ Ollama Cloud models (provider `ollama-cloud` or any model hosted on `ollama.com`) call Ollama's standalone web search API rather than a model tool. Auth is `OLLAMA_API_KEY` or `/login ollama-cloud`. Any `urls` are fetched through `web_fetch`. A local Ollama daemon is out of scope — the official `@ollama/pi-web-search` package covers its `/api/experimental/*` endpoints.
25
31
 
26
32
  Supports passing up to 20 additional URLs to analyze alongside the query. Successful `web_search` results are collapsed by default in pi; expand the tool call to inspect the full answer and source details.
27
33
 
28
34
  ### `url_context`
29
35
 
30
- Gemini-only. Analyze up to 20 public URLs — web pages, documents, images, and YouTube videos. Uses Gemini's native URL Context retrieval with verified metadata.
36
+ Gemini and Ollama Cloud. Analyze up to 20 public URLs — web pages, documents, images, and YouTube videos. Gemini uses native URL Context retrieval with verified metadata; Ollama uses its `web_fetch` endpoint (web pages and documents only).
31
37
 
32
38
  When using `google-generative-ai`, YouTube URLs are passed as `file_data` for native video understanding.
33
39
 
@@ -54,9 +60,37 @@ When this file exists, `web_search` uses the configured provider/model first. If
54
60
 
55
61
  For OpenAI Responses models (including Azure, Codex, and Copilot), `web_search` inherits the agent's current thinking level on each call. Enabled levels are clamped to the selected search model's supported levels and translated through its `thinkingLevelMap` using pi's model metadata. This also applies when `web-search.json` selects a dedicated search model. Higher effort can increase latency and cost.
56
62
 
57
- When thinking is off or unavailable, or the search model is non-reasoning, the request omits `reasoning` and leaves the choice to the provider. Off does not force reasoning off: some models reject `reasoning.effort: "none"`. Google, Anthropic, and xAI behavior is unchanged.
63
+ When thinking is off or unavailable, or the search model is non-reasoning, the request omits `reasoning` and leaves the choice to the provider. Off does not force reasoning off: some models reject `reasoning.effort: "none"`. Google, Anthropic, xAI, and Ollama behavior is unchanged.
64
+
65
+ `url_context` is automatically removed from active tools when using a model that supports neither Gemini URL Context nor Ollama web fetch.
66
+
67
+ ### DeepSeek
68
+
69
+ Select a model from pi's `deepseek` provider and authenticate with `/login` or
70
+ `DEEPSEEK_API_KEY`. Search automatically uses DeepSeek's Anthropic-compatible
71
+ endpoint (`https://api.deepseek.com/anthropic/v1/messages`) with the same model
72
+ and credentials. A configured proxy base URL is preserved and routed through
73
+ its `/anthropic/v1/messages` endpoint; the proxy must support that route.
74
+
75
+ To use DeepSeek search with another conversation model, set `web-search.json`:
76
+
77
+ ```json
78
+ {
79
+ "provider": "deepseek",
80
+ "model": "deepseek-v4-flash"
81
+ }
82
+ ```
83
+
84
+ The selected model must be registered in pi and support DeepSeek's server-side
85
+ search. Unlike the standalone `pi-deepseek-search` extension, this integration
86
+ uses the model selected by this project's configuration; it does not read
87
+ `DEEPSEEK_SEARCH_MODEL` or silently switch models. Do not load both extensions,
88
+ since they both register `web_search`.
89
+
90
+ DeepSeek requests support cancellation through pi. Additional `urls` are included in
91
+ the prompt; Gemini's verified URL Context retrieval remains Gemini-only.
58
92
 
59
- `url_context` is automatically removed from active tools when using a non-Gemini model.
93
+ This integration was informed by [pi-deepseek-search](https://github.com/bxff/pi-deepseek-search).
60
94
 
61
95
  ## Test
62
96
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-web-search",
3
- "version": "1.6.0",
4
- "description": "Provider-native web search for pi: Gemini + URL Context, xAI Grok, OpenAI Responses (Azure/Codex/Copilot), Anthropic, and OpenCode Zen/Go",
3
+ "version": "1.7.0",
4
+ "description": "Provider-native web search for pi: Gemini + URL Context, xAI Grok, OpenAI Responses (Azure/Codex/Copilot), Anthropic, DeepSeek, and OpenCode Zen/Go",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "keywords": [
@@ -15,6 +15,7 @@
15
15
  "grok",
16
16
  "openai",
17
17
  "anthropic",
18
+ "deepseek",
18
19
  "opencode",
19
20
  "opencode-zen",
20
21
  "url-context",
package/src/api.ts CHANGED
@@ -4,6 +4,7 @@ import { getProviderKind } from "./providers/config.ts";
4
4
  import { callGoogleStream, extractPromptFromGeminiBody } from "./providers/google.ts";
5
5
  import { callOpenAIStream } from "./providers/openai.ts";
6
6
  import { callAnthropicStream } from "./providers/anthropic.ts";
7
+ import { callOllamaSearch } from "./providers/ollama.ts";
7
8
  import type { StreamResult } from "./providers/types.ts";
8
9
 
9
10
  export { getProviderKind, getConfig } from "./providers/config.ts";
@@ -16,7 +17,8 @@ export async function callApiStream(
16
17
  body: any,
17
18
  onUpdate?: AgentToolUpdateCallback,
18
19
  signal?: AbortSignal,
19
- thinkingLevel?: ModelThinkingLevel
20
+ thinkingLevel?: ModelThinkingLevel,
21
+ urls?: string[]
20
22
  ): Promise<StreamResult> {
21
23
  const kind = getProviderKind(model);
22
24
  if (kind === "google") {
@@ -28,10 +30,16 @@ export async function callApiStream(
28
30
  throw new Error("No prompt text found in request body");
29
31
  }
30
32
 
33
+ // Ollama search is a standalone REST call, not a model tool — the prompt
34
+ // carries the embedded URL list only for other providers, so strip it here.
35
+ if (kind === "ollama") {
36
+ return callOllamaSearch(ctx, model, prompt, urls, onUpdate, signal);
37
+ }
38
+
31
39
  if (kind === "openai" || kind === "xai") {
32
40
  return callOpenAIStream(ctx, model, prompt, onUpdate, signal, thinkingLevel);
33
41
  }
34
- if (kind === "anthropic") {
42
+ if (kind === "anthropic" || kind === "deepseek") {
35
43
  return callAnthropicStream(ctx, model, prompt, onUpdate, signal);
36
44
  }
37
45
 
package/src/index.ts CHANGED
@@ -9,7 +9,11 @@ const WEB_SEARCH_TOOL = "web_search";
9
9
  const URL_CONTEXT_TOOL = "url_context";
10
10
 
11
11
  function supportsUrlContext(model: Model<Api> | undefined) {
12
- return !!model && getProviderKind(model) === "google";
12
+ if (!model) return false;
13
+ const kind = getProviderKind(model);
14
+ // Ollama serves URL retrieval through its web_fetch endpoint instead of
15
+ // Gemini URL Context; the tool dispatch handles both.
16
+ return kind === "google" || kind === "ollama";
13
17
  }
14
18
 
15
19
  function setEquals<T>(a: Set<T>, b: Set<T>) {
@@ -63,7 +67,7 @@ export default function (pi: ExtensionAPI) {
63
67
  pi.registerTool({
64
68
  name: WEB_SEARCH_TOOL,
65
69
  label: "Web Search",
66
- description: "Search the web using the current supported provider (Google Gemini, xAI Grok, OpenAI, Anthropic, or OpenCode Zen/Go). Optionally include URLs to analyze alongside search results.",
70
+ description: "Search the web using the current supported provider (Google Gemini, xAI Grok, OpenAI, Anthropic, DeepSeek, Ollama, or OpenCode Zen/Go). Optionally include URLs to analyze alongside search results.",
67
71
  parameters: WebSearchSchema,
68
72
  execute: (id, params, signal = new AbortController().signal, onUpdate, ctx): Promise<AgentToolResult<any>> =>
69
73
  webSearch(id, params, signal, onUpdate, ctx, pi.getThinkingLevel()),
@@ -93,7 +97,7 @@ export default function (pi: ExtensionAPI) {
93
97
  pi.registerTool({
94
98
  name: URL_CONTEXT_TOOL,
95
99
  label: "URL Context",
96
- description: "Analyze the content of up to 20 public URLs using Gemini URL Context. Supports web pages, documents, images, and YouTube videos.",
100
+ description: "Analyze the content of up to 20 public URLs using provider-native URL retrieval (Gemini URL Context or Ollama web fetch). Supports web pages and documents; images and YouTube videos need Gemini.",
97
101
  parameters: UrlContextSchema,
98
102
  execute: urlContext
99
103
  });
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext, AgentToolUpdateCallback } from "@earendil-works/pi-coding-agent";
2
2
  import type { Api, Model } from "@earendil-works/pi-ai";
3
3
  import { getAuth, getProviderSessionHeaders } from "./auth.ts";
4
+ import { getProviderKind } from "./config.ts";
4
5
  import { readSseEvents } from "./sse.ts";
5
6
  import {
6
7
  applyTextCitations,
@@ -21,6 +22,14 @@ function resolveAnthropicMessagesUrl(baseUrl: string): string {
21
22
  return base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
22
23
  }
23
24
 
25
+ // DeepSeek exposes server-side search only on its Anthropic-compatible route.
26
+ // Preserve a configured proxy origin/path instead of sending its credentials elsewhere.
27
+ export function resolveDeepSeekBaseUrl(baseUrl: string): string {
28
+ const base = baseUrl.replace(/\/+$/, "");
29
+ if (/\/anthropic(?:\/v1)?$/.test(base)) return base;
30
+ return `${base.replace(/\/v1$/, "")}/anthropic`;
31
+ }
32
+
24
33
  export async function callAnthropicStream(
25
34
  ctx: ExtensionContext,
26
35
  model: Model<Api>,
@@ -28,12 +37,15 @@ export async function callAnthropicStream(
28
37
  onUpdate?: AgentToolUpdateCallback,
29
38
  signal?: AbortSignal
30
39
  ): Promise<StreamResult> {
40
+ const kind = getProviderKind(model) === "deepseek" ? "deepseek" : "anthropic";
41
+ const isDeepSeek = kind === "deepseek";
42
+ const providerName = isDeepSeek ? "DeepSeek" : "Anthropic";
31
43
  const auth = await getAuth(ctx, model);
32
44
  if (!auth.ok) {
33
45
  throw new Error(auth.error || "Failed to get API key and headers");
34
46
  }
35
47
 
36
- const isOAuth = !!auth.apiKey && auth.apiKey.includes("sk-ant-oat");
48
+ const isOAuth = !isDeepSeek && !!auth.apiKey && auth.apiKey.includes("sk-ant-oat");
37
49
  const headers: Record<string, string> = {
38
50
  "Content-Type": "application/json",
39
51
  "Accept": "text/event-stream",
@@ -49,13 +61,19 @@ export async function callAnthropicStream(
49
61
  headers["anthropic-beta"] = headers["anthropic-beta"]
50
62
  ? `${headers["anthropic-beta"]},claude-code-20250219,oauth-2025-04-20`
51
63
  : "claude-code-20250219,oauth-2025-04-20";
52
- headers["user-agent"] = headers["user-agent"] || "claude-cli/2.1.75";
64
+ // Anthropic rejects OAuth requests from anything older than 2.1.251.
65
+ headers["user-agent"] = headers["user-agent"] || "claude-cli/2.1.251";
53
66
  headers["x-app"] = headers["x-app"] || "cli";
54
67
  } else if (!headers["x-api-key"] && !headers["X-Api-Key"]) {
55
68
  headers["x-api-key"] = auth.apiKey;
56
69
  }
57
70
  }
58
71
 
72
+ if (isDeepSeek && !Object.entries(headers).some(([name, value]) =>
73
+ value && ["authorization", "x-api-key"].includes(name.toLowerCase()))) {
74
+ throw new Error("No DeepSeek API key found. Run /login in pi and select DeepSeek, or set DEEPSEEK_API_KEY.");
75
+ }
76
+
59
77
  const maxTokens = Math.min(Math.max(1024, Math.floor(model.maxTokens / 3) || 4096), 8192);
60
78
  const requestBody = {
61
79
  model: model.id,
@@ -64,11 +82,17 @@ export async function callAnthropicStream(
64
82
  // system prompt, reporting it as an opaque 429 rate_limit_error.
65
83
  ...(isOAuth ? { system: [{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }] } : {}),
66
84
  messages: [{ role: "user", content: prompt }],
67
- tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 10 }],
85
+ tools: [{
86
+ type: "web_search_20260209",
87
+ name: "web_search",
88
+ max_uses: 10,
89
+ // Keep direct search compatible with models without programmatic tool calling.
90
+ allowed_callers: ["direct"],
91
+ }],
68
92
  stream: true,
69
93
  };
70
94
 
71
- const response = await fetch(resolveAnthropicMessagesUrl(model.baseUrl), {
95
+ const response = await fetch(resolveAnthropicMessagesUrl(isDeepSeek ? resolveDeepSeekBaseUrl(auth.baseUrl || model.baseUrl) : model.baseUrl), {
72
96
  method: "POST",
73
97
  headers,
74
98
  body: JSON.stringify(requestBody),
@@ -76,7 +100,7 @@ export async function callAnthropicStream(
76
100
  });
77
101
 
78
102
  if (!response.ok) {
79
- throw new Error(`Anthropic API error (${response.status}): ${await response.text()}`);
103
+ throw new Error(`${providerName} API error (${response.status}): ${await response.text()}`);
80
104
  }
81
105
 
82
106
  let accumulatedText = "";
@@ -93,7 +117,7 @@ export async function callAnthropicStream(
93
117
  title,
94
118
  url: source.url,
95
119
  pageAge: source.page_age ?? source.pageAge,
96
- source: "anthropic.web_search_tool_result",
120
+ source: `${kind}.web_search_tool_result`,
97
121
  type: source.type || "web_search_result",
98
122
  raw: { toolUseId, ...source },
99
123
  });
@@ -106,30 +130,31 @@ export async function callAnthropicStream(
106
130
  accumulatedText += block.text;
107
131
  onUpdate?.({ content: [{ type: "text", text: accumulatedText }], details: { streaming: true } });
108
132
  } else if (block?.type === "server_tool_use" && block.name === "web_search") {
109
- pushNativeSearchEvent(nativeSearchEvents, "anthropic.content_block_start.server_tool_use.web_search");
133
+ pushNativeSearchEvent(nativeSearchEvents, `${kind}.content_block_start.server_tool_use.web_search`);
110
134
  nativeSearchCalls.push({
111
135
  id: block.id,
112
- provider: "anthropic",
136
+ provider: kind,
113
137
  status: "in_progress",
114
138
  actionType: block.name,
115
139
  queries: typeof block.input?.query === "string" ? [block.input.query] : undefined,
116
140
  raw: block,
117
141
  });
118
142
  onUpdate?.({
119
- content: [{ type: "text", text: accumulatedText || "Searching the web with Anthropic..." }],
143
+ content: [{ type: "text", text: accumulatedText || `Searching the web with ${providerName}...` }],
120
144
  details: { streaming: true, searching: true }
121
145
  });
122
146
  } else if (block?.type === "web_search_tool_result") {
123
- pushNativeSearchEvent(nativeSearchEvents, "anthropic.content_block_start.web_search_tool_result");
147
+ pushNativeSearchEvent(nativeSearchEvents, `${kind}.content_block_start.web_search_tool_result`);
124
148
  const call = nativeSearchCalls.find((item) => item.id === block.tool_use_id);
125
149
  if (call) call.status = "completed";
126
- else nativeSearchCalls.push({ id: block.tool_use_id, provider: "anthropic", status: "completed", actionType: "web_search", raw: block });
150
+ else nativeSearchCalls.push({ id: block.tool_use_id, provider: kind, status: "completed", actionType: "web_search", raw: block });
127
151
  if (Array.isArray(block.content)) {
128
152
  for (const result of block.content) collectSource(result, block.tool_use_id);
129
153
  } else if (block.content?.type === "web_search_tool_result_error") {
154
+ if (isDeepSeek) throw new Error(`DeepSeek web search failed: ${block.content.error_code || "unknown error"}`);
130
155
  pushUniqueSearchResult(searchResults, {
131
156
  status: block.content.error_code,
132
- source: "anthropic.web_search_tool_result_error",
157
+ source: `${kind}.web_search_tool_result_error`,
133
158
  type: block.content.type,
134
159
  raw: block,
135
160
  });
@@ -150,7 +175,7 @@ export async function callAnthropicStream(
150
175
  citedText: citation.cited_text,
151
176
  title: citation.title || titleFromUrl(citation.url),
152
177
  url: citation.url,
153
- source: "anthropic.citations_delta",
178
+ source: `${kind}.citations_delta`,
154
179
  type: citation.type,
155
180
  raw: citation,
156
181
  };
@@ -168,7 +193,7 @@ export async function callAnthropicStream(
168
193
  title: citation.title || titleFromUrl(citation.url),
169
194
  url: citation.url,
170
195
  citedText: citation.citedText,
171
- source: "anthropic.citation",
196
+ source: `${kind}.citation`,
172
197
  type: "citation",
173
198
  raw: citation,
174
199
  }));
@@ -181,7 +206,7 @@ export async function callAnthropicStream(
181
206
  return {
182
207
  text: cited.text,
183
208
  sources: cited.sources.length ? normalizeCitedSources(cited.sources) : derivedSources,
184
- providerKind: "anthropic",
209
+ providerKind: kind,
185
210
  nativeSearchUsed: nativeSearchEvents.length > 0 || nativeSearchCalls.length > 0 || sanitizedSearchResults.length > 0,
186
211
  nativeSearchEvents,
187
212
  nativeSearchCalls,
@@ -1,7 +1,8 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import type { ProviderKind } from "./types.ts";
3
+ import type { ResolvedAuth } from "./auth.ts";
3
4
 
4
- export type GoogleRequestBuilder = (model: Model<Api>, body: any) => { url: string; headers: Record<string, string>; body: any };
5
+ export type GoogleRequestBuilder = (model: Model<Api>, body: any, auth?: ResolvedAuth) => { url: string; headers: Record<string, string>; body: any };
5
6
 
6
7
  export type ProviderConfig = {
7
8
  kind: ProviderKind;
@@ -10,6 +11,13 @@ export type ProviderConfig = {
10
11
  buildRequest?: GoogleRequestBuilder;
11
12
  };
12
13
 
14
+ const ANTIGRAVITY_MODEL_MAP: Record<string, string> = {
15
+ "gemini-3.7-flash": "gemini-3.7-flash-medium",
16
+ "gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
17
+ "gemini-3.6-flash": "gemini-3.6-flash-low",
18
+ "gemini-3.5-flash": "gemini-3.5-flash-extra-low",
19
+ };
20
+
13
21
  const GOOGLE_PROVIDERS: Record<string, ProviderConfig> = {
14
22
  "google-generative-ai": {
15
23
  kind: "google",
@@ -23,11 +31,63 @@ const GOOGLE_PROVIDERS: Record<string, ProviderConfig> = {
23
31
  },
24
32
  body
25
33
  })
34
+ },
35
+ "antigravity": {
36
+ kind: "google",
37
+ searchTool: "google_search",
38
+ urlContextTool: "url_context",
39
+ buildRequest: (model, body, auth) => {
40
+ let token = "";
41
+ let projectId = "aicode-consumers";
42
+ if (auth?.ok && auth.apiKey) {
43
+ try {
44
+ const parsed = JSON.parse(auth.apiKey);
45
+ token = parsed.token || auth.apiKey;
46
+ projectId = parsed.projectId || projectId;
47
+ } catch {
48
+ token = auth.apiKey;
49
+ }
50
+ }
51
+ const runtimeModel = ANTIGRAVITY_MODEL_MAP[model.id] || model.id;
52
+ const platform = process.platform === "darwin" ? "MACOS" : process.platform === "win32" ? "WINDOWS" : "LINUX";
53
+ const osType = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux";
54
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
55
+ return {
56
+ url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse",
57
+ headers: {
58
+ "Authorization": `Bearer ${token}`,
59
+ "Content-Type": "application/json",
60
+ "Accept": "text/event-stream",
61
+ "User-Agent": `antigravity/hub/2.8.0 ${osType}/${arch}`,
62
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
63
+ "Client-Metadata": JSON.stringify({ ideType: "ANTIGRAVITY", platform, pluginType: "GEMINI" })
64
+ },
65
+ body: {
66
+ project: projectId,
67
+ model: runtimeModel,
68
+ request: { contents: body.contents, ...(body.tools ? { tools: body.tools } : {}) },
69
+ requestType: "AGENT",
70
+ userAgent: "antigravity"
71
+ }
72
+ };
73
+ }
26
74
  }
27
75
  };
28
76
 
77
+ function isOllamaModel(model: Model<Api>): boolean {
78
+ if (model.provider === "ollama-cloud") return true;
79
+ try {
80
+ return new URL(model.baseUrl).hostname === "ollama.com";
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
29
86
  export function getProviderKind(model: Model<Api>): ProviderKind {
87
+ if (model.provider === "deepseek") return "deepseek";
88
+ if (model.provider === "antigravity" || model.api === "antigravity") return "google";
30
89
  if (GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api]) return "google";
90
+ if (isOllamaModel(model)) return "ollama";
31
91
  if (model.provider === "xai" && model.api === "openai-responses") return "xai";
32
92
  if (
33
93
  model.api === "openai-responses"
@@ -38,6 +98,29 @@ export function getProviderKind(model: Model<Api>): ProviderKind {
38
98
  return "unsupported";
39
99
  }
40
100
 
101
+ export type SearchDialect = "grok" | "openai";
102
+
103
+ // Optional models.json override: compat.webSearchDialect. Not part of pi-ai's
104
+ // compat types, so read it through a local cast and ignore unknown values.
105
+ function dialectOverride(model: Model<Api>): SearchDialect | undefined {
106
+ const value = (model.compat as { webSearchDialect?: unknown } | undefined)?.webSearchDialect;
107
+ return value === "grok" || value === "openai" ? value : undefined;
108
+ }
109
+
110
+ // Wire shape only (include, input, citation parsing). Grok is also served through
111
+ // third-party OpenAI-compatible Responses gateways whose provider kind is "openai";
112
+ // those reject web_search_call.results with 400 and return inline citations.
113
+ // Provider identity (auth, labels, reasoning semantics) stays with getProviderKind.
114
+ export function getSearchDialect(model: Model<Api>): SearchDialect {
115
+ const override = dialectOverride(model);
116
+ if (override) return override;
117
+ if (getProviderKind(model) === "xai") return "grok";
118
+ // Default only, matching official ids (grok-4.6, grok-4-fast, grok-code-fast-1).
119
+ // Prefixed or renamed ids opt in with compat.webSearchDialect.
120
+ if (/^grok([.-]|$)/i.test(model.id)) return "grok";
121
+ return "openai";
122
+ }
123
+
41
124
  export function getConfig(model: Model<Api>): ProviderConfig {
42
125
  const googleConfig = GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api];
43
126
  if (googleConfig) return googleConfig;
@@ -103,14 +103,14 @@ export async function callGoogleStream(
103
103
  throw new Error(auth.error || "Failed to get API key and headers");
104
104
  }
105
105
 
106
- const req = config.buildRequest(model, body);
106
+ const req = config.buildRequest(model, body, auth);
107
107
 
108
108
  // Handle auth
109
109
  Object.assign(req.headers, getProviderSessionHeaders(model, ctx) || {});
110
110
  if (auth.headers) {
111
111
  Object.assign(req.headers, auth.headers);
112
112
  }
113
- if (auth.apiKey) {
113
+ if (auth.apiKey && model.provider !== "antigravity" && model.api !== "antigravity") {
114
114
  req.headers["x-goog-api-key"] = auth.apiKey;
115
115
  }
116
116
 
@@ -0,0 +1,239 @@
1
+ import type { ExtensionContext, AgentToolUpdateCallback } from "@earendil-works/pi-coding-agent";
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+ import { getAuth, type ResolvedAuth } from "./auth.ts";
4
+ import { titleFromUrl } from "./results.ts";
5
+ import type { SearchResultDetail, Source, StreamResult } from "./types.ts";
6
+
7
+ /**
8
+ * Ollama Cloud exposes search/fetch as standalone REST endpoints rather than
9
+ * model tools, so this module talks to the API directly:
10
+ * POST {root}/api/web_search and POST {root}/api/web_fetch
11
+ * Both require an Ollama API key (OLLAMA_API_KEY or /login ollama-cloud).
12
+ * A local Ollama daemon is out of scope — it proxies these endpoints through
13
+ * its own `ollama signin` session under /api/experimental/*, which the
14
+ * official @ollama/pi-web-search package already covers.
15
+ */
16
+
17
+ const MAX_RESULTS = 10;
18
+ const FETCH_CONTENT_LIMIT = 8000;
19
+
20
+ interface OllamaSearchResult {
21
+ title?: string;
22
+ url?: string;
23
+ content?: string;
24
+ }
25
+
26
+ interface OllamaFetchResult {
27
+ title?: string;
28
+ content?: string;
29
+ links?: string[];
30
+ }
31
+
32
+ /** Strip the OpenAI-compatible suffix so `/api/*` endpoints resolve. */
33
+ export function resolveOllamaApiRoot(model: Model<Api>, auth?: ResolvedAuth): string {
34
+ const base = ((auth?.ok && auth.baseUrl) || model.baseUrl || "").replace(/\/+$/, "");
35
+ return base.replace(/\/v\d+$/i, "");
36
+ }
37
+
38
+ async function ollamaPost<T>(
39
+ root: string,
40
+ path: string,
41
+ body: unknown,
42
+ apiKey: string,
43
+ signal?: AbortSignal,
44
+ ): Promise<T> {
45
+ const response = await fetch(`${root}${path}`, {
46
+ method: "POST",
47
+ headers: {
48
+ "Content-Type": "application/json",
49
+ Accept: "application/json",
50
+ Authorization: `Bearer ${apiKey}`,
51
+ },
52
+ body: JSON.stringify(body),
53
+ signal,
54
+ });
55
+ if (!response.ok) {
56
+ const text = await response.text().catch(() => "");
57
+ if (response.status === 401) {
58
+ throw new Error("Ollama API key missing or invalid — set OLLAMA_API_KEY or run /login ollama-cloud.");
59
+ }
60
+ throw new Error(`Ollama API error (${response.status}): ${text || response.statusText}`);
61
+ }
62
+ return (await response.json()) as T;
63
+ }
64
+
65
+ function truncateContent(text: string | undefined, limit = FETCH_CONTENT_LIMIT): string {
66
+ if (!text) return "";
67
+ return text.length > limit ? `${text.slice(0, limit)}…` : text;
68
+ }
69
+
70
+ interface FetchedPage {
71
+ url: string;
72
+ title?: string;
73
+ content?: string;
74
+ ok: boolean;
75
+ status?: string;
76
+ }
77
+
78
+ async function fetchPages(
79
+ root: string,
80
+ urls: string[],
81
+ apiKey: string,
82
+ signal?: AbortSignal,
83
+ ): Promise<FetchedPage[]> {
84
+ return Promise.all(
85
+ urls.map(async (url): Promise<FetchedPage> => {
86
+ try {
87
+ const data = await ollamaPost<OllamaFetchResult>(root, "/api/web_fetch", { url }, apiKey, signal);
88
+ return { url, title: data.title, content: data.content, ok: true };
89
+ } catch (error) {
90
+ return {
91
+ url,
92
+ ok: false,
93
+ status: error instanceof Error ? error.message : String(error),
94
+ };
95
+ }
96
+ }),
97
+ );
98
+ }
99
+
100
+ /** URL metadata in the same shape Gemini's url_context produces downstream. */
101
+ function toUrlContextMetadata(pages: FetchedPage[]) {
102
+ return {
103
+ urlMetadata: pages.map((page) => ({
104
+ retrievedUrl: page.url,
105
+ urlRetrievalStatus: page.ok ? "URL_RETRIEVAL_STATUS_SUCCESS" : "URL_RETRIEVAL_STATUS_ERROR",
106
+ })),
107
+ };
108
+ }
109
+
110
+ async function resolveOllamaTarget(
111
+ ctx: ExtensionContext,
112
+ model: Model<Api>,
113
+ ): Promise<{ root: string; apiKey: string }> {
114
+ const auth = await getAuth(ctx, model);
115
+ if (!auth.ok) throw new Error(auth.error || "Failed to resolve Ollama credentials");
116
+ const apiKey = auth.apiKey || process.env.OLLAMA_API_KEY;
117
+ if (!apiKey) {
118
+ throw new Error(
119
+ "Ollama web search needs an API key — set OLLAMA_API_KEY or run /login ollama-cloud.",
120
+ );
121
+ }
122
+ return { root: resolveOllamaApiRoot(model, auth), apiKey };
123
+ }
124
+
125
+ export async function callOllamaSearch(
126
+ ctx: ExtensionContext,
127
+ model: Model<Api>,
128
+ query: string,
129
+ urls?: string[],
130
+ onUpdate?: AgentToolUpdateCallback,
131
+ signal?: AbortSignal,
132
+ ): Promise<StreamResult> {
133
+ const { root, apiKey } = await resolveOllamaTarget(ctx, model);
134
+
135
+ onUpdate?.({
136
+ content: [{ type: "text", text: `Searching the web with Ollama...` }],
137
+ details: { streaming: true, searching: true },
138
+ });
139
+
140
+ const data = await ollamaPost<{ results?: OllamaSearchResult[] }>(
141
+ root,
142
+ "/api/web_search",
143
+ { query, max_results: MAX_RESULTS },
144
+ apiKey,
145
+ signal,
146
+ );
147
+ const hits = (data.results ?? []).filter((r) => r && typeof r.url === "string");
148
+
149
+ const searchResults: SearchResultDetail[] = hits.map((hit) => ({
150
+ title: hit.title || titleFromUrl(hit.url!),
151
+ url: hit.url,
152
+ citedText: hit.content,
153
+ source: "ollama.web_search",
154
+ type: "search_result",
155
+ query,
156
+ raw: hit,
157
+ }));
158
+ const sources: Source[] = hits.map((hit) => ({
159
+ title: hit.title || titleFromUrl(hit.url!),
160
+ url: hit.url!,
161
+ }));
162
+
163
+ const lines = [`Search results for "${query}" via Ollama:`];
164
+ if (hits.length === 0) lines.push("No results found.");
165
+ hits.forEach((hit, i) => {
166
+ lines.push(`\n${i + 1}. ${hit.title || hit.url}\n ${hit.url}${hit.content ? `\n ${hit.content}` : ""}`);
167
+ });
168
+
169
+ // Optional URL analysis through web_fetch (the fetch half of the API pair).
170
+ let urlContextMetadata;
171
+ if (urls?.length) {
172
+ onUpdate?.({
173
+ content: [{ type: "text", text: `Fetching ${urls.length} URL(s) with Ollama...` }],
174
+ details: { streaming: true },
175
+ });
176
+ const pages = await fetchPages(root, urls, apiKey, signal);
177
+ urlContextMetadata = toUrlContextMetadata(pages);
178
+ for (const page of pages) {
179
+ if (!page.ok) continue;
180
+ lines.push(
181
+ `\n## Fetched: ${page.title || page.url}\n${page.url}\n\n${truncateContent(page.content)}`,
182
+ );
183
+ if (!sources.some((s) => s.url === page.url)) {
184
+ sources.push({ title: page.title || titleFromUrl(page.url), url: page.url });
185
+ }
186
+ }
187
+ }
188
+
189
+ return {
190
+ text: lines.join("\n"),
191
+ sources,
192
+ providerKind: "ollama",
193
+ nativeSearchUsed: true,
194
+ searchQueries: [query],
195
+ searchResults,
196
+ urlContextMetadata,
197
+ };
198
+ }
199
+
200
+ /** url_context for Ollama: fetch each URL through web_fetch, no model involved. */
201
+ export async function callOllamaUrlContext(
202
+ ctx: ExtensionContext,
203
+ model: Model<Api>,
204
+ query: string,
205
+ urls: string[],
206
+ signal?: AbortSignal,
207
+ ): Promise<StreamResult> {
208
+ const { root, apiKey } = await resolveOllamaTarget(ctx, model);
209
+ const pages = await fetchPages(root, urls, apiKey, signal);
210
+
211
+ const lines = [`URL contents for: ${query}`];
212
+ const sources: Source[] = [];
213
+ const searchResults: SearchResultDetail[] = [];
214
+ for (const page of pages) {
215
+ if (!page.ok) {
216
+ lines.push(`\n## Failed: ${page.url}\n${page.status}`);
217
+ continue;
218
+ }
219
+ lines.push(`\n## ${page.title || page.url}\n${page.url}\n\n${truncateContent(page.content)}`);
220
+ sources.push({ title: page.title || titleFromUrl(page.url), url: page.url });
221
+ searchResults.push({
222
+ title: page.title,
223
+ url: page.url,
224
+ citedText: truncateContent(page.content),
225
+ source: "ollama.web_fetch",
226
+ type: "fetched_page",
227
+ raw: page,
228
+ });
229
+ }
230
+
231
+ return {
232
+ text: lines.join("\n"),
233
+ sources,
234
+ providerKind: "ollama",
235
+ nativeSearchUsed: false,
236
+ searchResults,
237
+ urlContextMetadata: toUrlContextMetadata(pages),
238
+ };
239
+ }
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext, AgentToolUpdateCallback } from "@earendil-works/pi-coding-agent";
2
2
  import { clampThinkingLevel, type Api, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
3
3
  import { TextDecoder } from "util";
4
- import { getProviderKind } from "./config.ts";
4
+ import { getProviderKind, getSearchDialect } from "./config.ts";
5
5
  import { getAuth, getProviderSessionHeaders, type ResolvedAuth } from "./auth.ts";
6
6
  import { readSseEvents } from "./sse.ts";
7
7
  import {
@@ -138,17 +138,20 @@ export async function callOpenAIStream(
138
138
  const requestHeaders = Object.fromEntries(headers.entries());
139
139
 
140
140
  const isXai = getProviderKind(model) === "xai";
141
+ // Dialect shapes the wire format only (input, include, citations); labels,
142
+ // providerKind, and reasoning stay keyed to provider identity.
143
+ const isGrok = getSearchDialect(model) === "grok";
141
144
  const searchProvider: "xai" | "openai" = isXai ? "xai" : "openai";
142
145
  const providerSourceName = isXai ? "xai" : "openai";
143
146
  const requestBody: any = {
144
147
  model: model.id,
145
148
  input: isCodex
146
149
  ? [{ role: "user", content: [{ type: "input_text", text: prompt }] }]
147
- : isXai
150
+ : isGrok
148
151
  ? [{ role: "user", content: prompt }]
149
- : prompt,
152
+ : [{ role: "user", content: [{ type: "input_text", text: prompt }] }],
150
153
  tools: [{ type: "web_search" }],
151
- ...(isCodex || isXai
154
+ ...(isCodex || isGrok
152
155
  ? { include: ["web_search_call.action.sources"] }
153
156
  : { include: ["web_search_call.action.sources", "web_search_call.results"] }),
154
157
  stream: true,
@@ -158,6 +161,7 @@ export async function callOpenAIStream(
158
161
  // model (which can differ from the conversation model). Keep provider defaults
159
162
  // when thinking is off/unavailable; do not reintroduce an implicit "none".
160
163
  // xAI shares this transport but does not share OpenAI's effort semantics.
164
+ // Keyed to provider identity, not dialect: gateway-hosted Grok keeps effort.
161
165
  if (!isXai && model.reasoning && thinkingLevel && thinkingLevel !== "off") {
162
166
  const level = clampThinkingLevel(model, thinkingLevel);
163
167
  const effort = model.thinkingLevelMap?.[level] ?? level;
@@ -292,7 +296,7 @@ export async function callOpenAIStream(
292
296
  }
293
297
  });
294
298
 
295
- const cited = isXai
299
+ const cited = isGrok
296
300
  ? preserveInlineCitations(accumulatedText || "No answer available.", citations)
297
301
  : applyIndexCitations(accumulatedText || "No answer available.", citations);
298
302
  const citationDetails = citations.map((citation) => ({
@@ -1,4 +1,4 @@
1
- export type ProviderKind = "google" | "openai" | "xai" | "anthropic" | "unsupported";
1
+ export type ProviderKind = "google" | "openai" | "xai" | "anthropic" | "deepseek" | "ollama" | "unsupported";
2
2
 
3
3
  export interface Source {
4
4
  title: string;
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext, AgentToolUpdateCallback } from "@earendil-works/pi-coding-agent";
2
2
  import { Type, type Static } from "typebox";
3
3
  import { callApiStream, getConfig } from "./api.ts";
4
+ import { callOllamaUrlContext } from "./providers/ollama.ts";
4
5
  import { formatResult, formatUrlContextResult } from "./format.ts";
5
6
  import { getModel, missingConfigResult, errorResult } from "./utils.ts";
6
7
 
@@ -31,6 +32,10 @@ export async function urlContext(
31
32
 
32
33
  try {
33
34
  const config = getConfig(model);
35
+ if (config.kind === "ollama") {
36
+ const result = await callOllamaUrlContext(ctx, model, params.query, params.urls, signal);
37
+ return formatUrlContextResult(result, { modelId: model.id });
38
+ }
34
39
  if (config.kind !== "google") {
35
40
  return formatResult(
36
41
  `url_context currently requires a Google Gemini-compatible model. Current model: ${model.id} (${model.provider}/${model.api}).\n\nUse web_search for cross-provider web search, or switch to Gemini for provider-native URL context retrieval.`,
package/src/utils.ts CHANGED
@@ -7,7 +7,7 @@ import { getProviderKind } from "./api.ts";
7
7
 
8
8
  // --- Model Selection ---
9
9
 
10
- const SUPPORTED_PROVIDERS = ["google-generative-ai", "xai", "openai-responses", "azure-openai-responses", "openai-codex-responses", "anthropic-messages"];
10
+ const SUPPORTED_PROVIDERS = ["deepseek", "google-generative-ai", "antigravity", "xai", "openai-responses", "azure-openai-responses", "openai-codex-responses", "anthropic-messages", "ollama"];
11
11
 
12
12
  type WebSearchModelConfig =
13
13
  | { status: "missing"; path: string; }
package/src/web_search.ts CHANGED
@@ -40,9 +40,11 @@ export async function webSearch(
40
40
 
41
41
  try {
42
42
  const config = getConfig(model);
43
-
44
- // Build prompt: include URLs if provided
45
- const prompt = hasUrls
43
+
44
+ // Build prompt: include URLs if provided. Ollama receives the URL list as
45
+ // a separate argument (its search is a REST endpoint, not a model tool),
46
+ // so the prompt stays a clean query.
47
+ const prompt = hasUrls && config.kind !== "ollama"
46
48
  ? `${params.query}\n\nAlso analyze these URLs:\n${params.urls!.join("\n")}`
47
49
  : params.query;
48
50
 
@@ -57,7 +59,7 @@ export async function webSearch(
57
59
  const result = await callApiStream(ctx, model, {
58
60
  contents: [{ role: "user", parts: [{ text: prompt }] }],
59
61
  ...(tools ? { tools } : {})
60
- }, onUpdate, signal, thinkingLevel);
62
+ }, onUpdate, signal, thinkingLevel, params.urls);
61
63
 
62
64
  return formatWebSearchResult(result, { modelId: model.id });
63
65
  } catch (e: any) {