Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# pi-web-search

Provider-native web search for [pi](https://pi.dev) with Gemini + URL Context, xAI Grok, OpenAI Responses variants, Anthropic, Ollama Cloud, and OpenCode Zen/Go.
Provider-native web search for [pi](https://pi.dev) with Gemini + URL Context, xAI Grok, OpenAI Responses variants, Anthropic, Ollama Cloud, OpenCodex, and OpenCode Zen/Go.

## Tools

Expand All @@ -18,6 +18,7 @@ Search the web using your currently selected model. Automatically picks the righ
| GitHub Copilot | OpenAI Responses API web search via Copilot credentials |
| Anthropic | Messages API web search |
| Ollama Cloud | Ollama web search API (`/api/web_search`, standalone REST) |
| OpenCodex | `/v1/alpha/search`, routed by OpenCodex to the current model's resolved provider |
| OpenCode Zen / Go | Responses API web search (models that use the `openai-responses` API) |

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`.
Expand All @@ -26,6 +27,8 @@ OpenCode Zen and OpenCode Go Responses models (for example `opencode-go/gpt-5.6-

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.

OpenCodex models send the current `model.id` to the configured OpenCodex base URL. OpenCodex resolves aliases, combos, and the real adapter, then uses that provider's native search path when available; it does not make pi choose or maintain a second search model. The generated `opencodex-loopback` placeholder is omitted from auth headers, while a real configured OpenCodex data-plane key is sent as `x-opencodex-api-key`.

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.

### `url_context`
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "pi-web-search",
"version": "1.6.0",
"description": "Provider-native web search for pi: Gemini + URL Context, xAI Grok, OpenAI Responses (Azure/Codex/Copilot), Anthropic, and OpenCode Zen/Go",
"description": "Provider-native web search for pi: Gemini, xAI, OpenAI, Anthropic, Ollama, OpenCodex, and OpenCode",
"type": "module",
"main": "src/index.ts",
"keywords": [
Expand All @@ -16,6 +16,7 @@
"openai",
"anthropic",
"opencode",
"opencodex",
"opencode-zen",
"url-context",
"url-analysis",
Expand Down
4 changes: 4 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { callGoogleStream, extractPromptFromGeminiBody } from "./providers/googl
import { callOpenAIStream } from "./providers/openai.ts";
import { callAnthropicStream } from "./providers/anthropic.ts";
import { callOllamaSearch } from "./providers/ollama.ts";
import { callOpenCodexSearch } from "./providers/opencodex.ts";
import type { StreamResult } from "./providers/types.ts";

export { getProviderKind, getConfig } from "./providers/config.ts";
Expand Down Expand Up @@ -35,6 +36,9 @@ export async function callApiStream(
if (kind === "ollama") {
return callOllamaSearch(ctx, model, prompt, urls, onUpdate, signal);
}
if (kind === "opencodex") {
return callOpenCodexSearch(ctx, model, prompt, onUpdate, signal);
}

if (kind === "openai" || kind === "xai") {
return callOpenAIStream(ctx, model, prompt, onUpdate, signal, thinkingLevel);
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export default function (pi: ExtensionAPI) {
pi.registerTool({
name: WEB_SEARCH_TOOL,
label: "Web Search",
description: "Search the web using the current supported provider (Google Gemini, xAI Grok, OpenAI, Anthropic, Ollama, or OpenCode Zen/Go). Optionally include URLs to analyze alongside search results.",
description: "Search the web using the current supported provider (Google Gemini, xAI Grok, OpenAI, Anthropic, Ollama, OpenCodex, or OpenCode Zen/Go). Optionally include URLs to analyze alongside search results.",
parameters: WebSearchSchema,
execute: (id, params, signal = new AbortController().signal, onUpdate, ctx): Promise<AgentToolResult<any>> =>
webSearch(id, params, signal, onUpdate, ctx, pi.getThinkingLevel()),
Expand Down
1 change: 1 addition & 0 deletions src/providers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ function isOllamaModel(model: Model<Api>): boolean {
}

export function getProviderKind(model: Model<Api>): ProviderKind {
if (model.provider === "opencodex") return "opencodex";
if (model.provider === "antigravity" || model.api === "antigravity") return "google";
if (GOOGLE_PROVIDERS[model.provider] || GOOGLE_PROVIDERS[model.api]) return "google";
if (isOllamaModel(model)) return "ollama";
Expand Down
119 changes: 119 additions & 0 deletions src/providers/opencodex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { ExtensionContext, AgentToolUpdateCallback } from "@earendil-works/pi-coding-agent";
import type { Api, Model } from "@earendil-works/pi-ai";
import { getAuth } from "./auth.ts";
import { deriveSources, sanitizeSearchResults, titleFromUrl } from "./results.ts";
import type { SearchResultDetail, StreamResult } from "./types.ts";

const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;

interface OpenCodexSearchResponse {
output?: unknown;
results?: unknown;
error?: { message?: unknown };
}

function searchUrl(baseUrl: string): string {
return `${baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "")}/v1/alpha/search`;
}

async function boundedBytes(response: Response): Promise<Uint8Array> {
const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
void response.body?.cancel();
throw new Error("OpenCodex search response exceeded 16 MiB");
}
if (!response.body) return new Uint8Array();
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_RESPONSE_BYTES) {
void reader.cancel();
throw new Error("OpenCodex search response exceeded 16 MiB");
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
return bytes;
}

export async function callOpenCodexSearch(
ctx: ExtensionContext,
model: Model<Api>,
query: string,
onUpdate?: AgentToolUpdateCallback,
signal?: AbortSignal,
): Promise<StreamResult> {
const auth = await getAuth(ctx, model);
if (!auth.ok) throw new Error(auth.error || "Failed to resolve OpenCodex credentials");

const headers = new Headers(model.headers || {});
for (const [name, value] of Object.entries(auth.headers || {})) headers.set(name, value);
headers.set("Content-Type", "application/json");
headers.set("Accept", "application/json");
if (auth.apiKey && auth.apiKey !== "opencodex-loopback" && !headers.has("x-opencodex-api-key")) {
headers.set("x-opencodex-api-key", auth.apiKey);
// This endpoint may relay a real ChatGPT bearer. OpenCodex admission keys belong in
// its dedicated header and must never be mistaken for an upstream credential.
headers.delete("Authorization");
}
const sessionId = ctx.sessionManager?.getSessionId?.();
const sessionAffinity = model.compat as { sendSessionAffinityHeaders?: boolean } | undefined;
if (sessionId && sessionAffinity?.sendSessionAffinityHeaders) {
headers.set("session_id", sessionId);
headers.set("x-client-request-id", sessionId);
headers.set("x-session-affinity", sessionId);
}

onUpdate?.({
content: [{ type: "text", text: "Searching the web through OpenCodex..." }],
details: { streaming: true, searching: true },
});

const response = await fetch(searchUrl(model.baseUrl), {
method: "POST",
headers: Object.fromEntries(headers.entries()),
body: JSON.stringify({
id: `pi-web-search-${crypto.randomUUID()}`,
model: model.id,
commands: { search_query: [{ q: query }] },
}),
signal,
});
const bytes = await boundedBytes(response);
let payload: OpenCodexSearchResponse = {};
try { payload = JSON.parse(new TextDecoder().decode(bytes)) as OpenCodexSearchResponse; } catch { /* handled below */ }
if (!response.ok) {
const message = typeof payload.error?.message === "string" ? payload.error.message : response.statusText;
throw new Error(`OpenCodex search error (${response.status}): ${message}`);
}

const rows = Array.isArray(payload.results) ? payload.results : [];
const searchResults: SearchResultDetail[] = [];
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const { title, url } = row as { title?: unknown; url?: unknown };
if (typeof url !== "string" || !url) continue;
const safeTitle = typeof title === "string" && title ? title : titleFromUrl(url);
searchResults.push({ title: safeTitle, url, query, source: "opencodex.alpha_search", type: "search_result" });
}
const sanitizedSearchResults = sanitizeSearchResults(searchResults);

return {
text: typeof payload.output === "string" ? payload.output : "No answer available.",
sources: deriveSources(sanitizedSearchResults),
providerKind: "opencodex",
nativeSearchUsed: true,
searchQueries: [query],
searchResults: sanitizedSearchResults,
};
}
1 change: 1 addition & 0 deletions src/providers/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function isLikelyJunkSearchUrl(url: string | undefined): boolean {
if (!url) return true;
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return true;
const decodedPath = decodeURIComponent(parsed.pathname).toLowerCase();
const suspiciousSuffixes = [
".gz", ".zip", ".tgz", ".tar", ".woff", ".woff2", ".ttf", ".otf", ".eot",
Expand Down
2 changes: 1 addition & 1 deletion src/providers/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type ProviderKind = "google" | "openai" | "xai" | "anthropic" | "ollama" | "unsupported";
export type ProviderKind = "google" | "openai" | "xai" | "anthropic" | "ollama" | "opencodex" | "unsupported";

export interface Source {
title: string;
Expand Down
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { getProviderKind } from "./api.ts";

// --- Model Selection ---

const SUPPORTED_PROVIDERS = ["google-generative-ai", "antigravity", "xai", "openai-responses", "azure-openai-responses", "openai-codex-responses", "anthropic-messages", "ollama"];
const SUPPORTED_PROVIDERS = ["google-generative-ai", "antigravity", "xai", "openai-responses", "azure-openai-responses", "openai-codex-responses", "anthropic-messages", "ollama", "opencodex"];

type WebSearchModelConfig =
| { status: "missing"; path: string; }
Expand Down
16 changes: 16 additions & 0 deletions tests/model-selection.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ test('getModel accepts openai-codex Responses models', async () => {
assert.equal(await getModel(ctx), model);
});

test('getModel accepts OpenCodex regardless of its pi wire API', async () => {
const model = {
id: 'devin/claude-sonnet-5',
provider: 'opencodex',
api: 'openai-completions',
baseUrl: 'http://127.0.0.1:10100/v1',
headers: {},
};
const ctx = {
model,
modelRegistry: { getAvailable: () => [model] },
};

assert.equal(await getModel(ctx), model);
});

test('getModel accepts xAI Responses models and rejects xAI Completions models', async () => {
const model = {
id: 'grok-4.6',
Expand Down
148 changes: 148 additions & 0 deletions tests/opencodex.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { callApiStream, getProviderKind } from '../src/api.ts';
import { createMockCtx as mockCtx } from './helpers.mjs';

const MODEL = {
id: 'devin/grok-4-7',
provider: 'opencodex',
api: 'openai-completions',
baseUrl: 'http://127.0.0.1:10100/v1',
headers: {},
compat: { sendSessionAffinityHeaders: true },
};

test('OpenCodex sends the current model to alpha/search and normalizes results', async (t) => {
let request;
t.mock.method(globalThis, 'fetch', async (url, init) => {
request = { url, headers: init.headers, body: JSON.parse(init.body) };
return Response.json({
encrypted_output: null,
output: 'Current answer from native search.',
results: [
{ title: 'Current docs', url: 'https://example.test/docs' },
{ title: 'Unsafe', url: 'javascript:alert(1)' },
],
});
});

const result = await callApiStream(
mockCtx('local-proxy-key', undefined, undefined, undefined, { sessionId: 'pi-session-1' }),
MODEL,
{ contents: [{ parts: [{ text: 'latest docs' }] }] },
);

assert.equal(getProviderKind(MODEL), 'opencodex');
assert.equal(request.url, 'http://127.0.0.1:10100/v1/alpha/search');
assert.equal(request.headers['x-opencodex-api-key'], 'local-proxy-key');
assert.equal(request.headers.authorization, undefined);
assert.equal(request.headers.session_id, 'pi-session-1');
assert.equal(request.headers['x-client-request-id'], 'pi-session-1');
assert.equal(request.headers['x-session-affinity'], 'pi-session-1');
assert.equal(request.body.model, 'devin/grok-4-7');
assert.deepEqual(request.body.commands, { search_query: [{ q: 'latest docs' }] });
assert.equal(result.text, 'Current answer from native search.');
assert.equal(result.providerKind, 'opencodex');
assert.equal(result.nativeSearchUsed, true);
assert.deepEqual(result.searchQueries, ['latest docs']);
assert.deepEqual(result.sources, [{ title: 'Current docs', url: 'https://example.test/docs' }]);
});

test('OpenCodex does not send its public loopback placeholder as admission auth', async (t) => {
t.mock.method(globalThis, 'fetch', async (_url, init) => {
assert.equal(init.headers['x-opencodex-api-key'], undefined);
assert.equal(init.headers.authorization, undefined);
return Response.json({ output: 'loopback', results: [] });
});

await callApiStream(
mockCtx('opencodex-loopback'),
MODEL,
{ contents: [{ parts: [{ text: 'loopback search' }] }] },
);
});

test('OpenCodex preserves caller headers when no API key is resolved', async (t) => {
t.mock.method(globalThis, 'fetch', async (_url, init) => {
assert.equal(init.headers.authorization, 'Bearer caller-chatgpt-token');
assert.equal(init.headers['chatgpt-account-id'], 'account-1');
return Response.json({ output: 'forwarded', results: [] });
});

await callApiStream(
mockCtx(undefined, undefined, {
Authorization: 'Bearer caller-chatgpt-token',
'chatgpt-account-id': 'account-1',
}),
MODEL,
{ contents: [{ parts: [{ text: 'forward search' }] }] },
);
});

test('OpenCodex preserves explicit admission and upstream auth headers together', async (t) => {
t.mock.method(globalThis, 'fetch', async (_url, init) => {
assert.equal(init.headers['x-opencodex-api-key'], 'explicit-admission-key');
assert.equal(init.headers.authorization, 'Bearer caller-chatgpt-token');
return Response.json({ output: 'forwarded', results: [] });
});

await callApiStream(
mockCtx('resolved-key-is-unused', undefined, {
'x-opencodex-api-key': 'explicit-admission-key',
Authorization: 'Bearer caller-chatgpt-token',
}),
MODEL,
{ contents: [{ parts: [{ text: 'forward search' }] }] },
);
});

test('OpenCodex rejects oversized responses before reading their body', async (t) => {
let canceled = false;
t.mock.method(globalThis, 'fetch', async () => new Response(new ReadableStream({
cancel() { canceled = true; },
}), { headers: { 'content-length': String(16 * 1024 * 1024 + 1) } }));

await assert.rejects(
callApiStream(
mockCtx('local-proxy-key'),
MODEL,
{ contents: [{ parts: [{ text: 'latest docs' }] }] },
),
/response exceeded 16 MiB/,
);
assert.equal(canceled, true);
});

test('OpenCodex cancels a streamed response once it crosses the byte bound', async (t) => {
let canceled = false;
const chunk = new Uint8Array(8 * 1024 * 1024 + 1);
t.mock.method(globalThis, 'fetch', async () => new Response(new ReadableStream({
start(controller) { controller.enqueue(chunk); controller.enqueue(chunk); },
cancel() { canceled = true; },
})));

await assert.rejects(
callApiStream(
mockCtx('local-proxy-key'),
MODEL,
{ contents: [{ parts: [{ text: 'latest docs' }] }] },
),
/response exceeded 16 MiB/,
);
assert.equal(canceled, true);
});

test('OpenCodex surfaces bounded endpoint errors without changing models', async (t) => {
t.mock.method(globalThis, 'fetch', async () => Response.json({
error: { message: 'Devin login required' },
}, { status: 502 }));

await assert.rejects(
callApiStream(
mockCtx('local-proxy-key'),
MODEL,
{ contents: [{ parts: [{ text: 'latest docs' }] }] },
),
/OpenCodex search error \(502\): Devin login required/,
);
});