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 +39 -5
- package/package.json +3 -2
- package/src/api.ts +10 -2
- package/src/index.ts +7 -3
- package/src/providers/anthropic.ts +40 -15
- package/src/providers/config.ts +84 -1
- package/src/providers/google.ts +2 -2
- package/src/providers/ollama.ts +239 -0
- package/src/providers/openai.ts +9 -5
- package/src/providers/types.ts +1 -1
- package/src/url_context.ts +5 -0
- package/src/utils.ts +1 -1
- package/src/web_search.ts +6 -4
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: [{
|
|
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(
|
|
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:
|
|
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,
|
|
133
|
+
pushNativeSearchEvent(nativeSearchEvents, `${kind}.content_block_start.server_tool_use.web_search`);
|
|
110
134
|
nativeSearchCalls.push({
|
|
111
135
|
id: block.id,
|
|
112
|
-
provider:
|
|
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 ||
|
|
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,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
209
|
+
providerKind: kind,
|
|
185
210
|
nativeSearchUsed: nativeSearchEvents.length > 0 || nativeSearchCalls.length > 0 || sanitizedSearchResults.length > 0,
|
|
186
211
|
nativeSearchEvents,
|
|
187
212
|
nativeSearchCalls,
|
package/src/providers/config.ts
CHANGED
|
@@ -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;
|
package/src/providers/google.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/providers/openai.ts
CHANGED
|
@@ -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
|
-
:
|
|
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 ||
|
|
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 =
|
|
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) => ({
|
package/src/providers/types.ts
CHANGED
package/src/url_context.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|