diff --git a/README.md b/README.md index c033d03..c6aa35f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`. @@ -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` diff --git a/package.json b/package.json index fb84b3b..8d09bbe 100644 --- a/package.json +++ b/package.json @@ -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": [ @@ -16,6 +16,7 @@ "openai", "anthropic", "opencode", + "opencodex", "opencode-zen", "url-context", "url-analysis", diff --git a/src/api.ts b/src/api.ts index da3ad1d..3746ca5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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"; @@ -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); diff --git a/src/index.ts b/src/index.ts index 69ed77c..9e6b109 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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> => webSearch(id, params, signal, onUpdate, ctx, pi.getThinkingLevel()), diff --git a/src/providers/config.ts b/src/providers/config.ts index 7bf12c8..5375c88 100644 --- a/src/providers/config.ts +++ b/src/providers/config.ts @@ -84,6 +84,7 @@ function isOllamaModel(model: Model): boolean { } export function getProviderKind(model: Model): 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"; diff --git a/src/providers/opencodex.ts b/src/providers/opencodex.ts new file mode 100644 index 0000000..4832978 --- /dev/null +++ b/src/providers/opencodex.ts @@ -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 { + 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, + query: string, + onUpdate?: AgentToolUpdateCallback, + signal?: AbortSignal, +): Promise { + 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, + }; +} diff --git a/src/providers/results.ts b/src/providers/results.ts index 9484b97..5325375 100644 --- a/src/providers/results.ts +++ b/src/providers/results.ts @@ -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", diff --git a/src/providers/types.ts b/src/providers/types.ts index 7de28c7..e24739f 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -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; diff --git a/src/utils.ts b/src/utils.ts index e178298..ff46cbc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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; } diff --git a/tests/model-selection.test.mjs b/tests/model-selection.test.mjs index 61698b4..b8eb2e1 100644 --- a/tests/model-selection.test.mjs +++ b/tests/model-selection.test.mjs @@ -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', diff --git a/tests/opencodex.test.mjs b/tests/opencodex.test.mjs new file mode 100644 index 0000000..d79677f --- /dev/null +++ b/tests/opencodex.test.mjs @@ -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/, + ); +});