Skip to content
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

### Fixed

- Fixed built-in web search discovery so model aliases sharing a provider endpoint no longer multiply serial fallback attempts, aborting a search stops pending native authentication discovery, and dotted private host spellings are rejected before auth or fetch ([upstream #5](https://github.com/code-yeongyu/pi-websearch/pull/5), [upstream #6](https://github.com/code-yeongyu/pi-websearch/pull/6)).

### Removed

## [2026.7.11] - 2026-07-11
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# changes.md — websearch (vendored)

Vendored from [`code-yeongyu/pi-websearch`](https://github.com/code-yeongyu/pi-websearch) at `0b9b44e83eef46121758f22965aadf59544faccf`.
Vendored from [`code-yeongyu/pi-websearch`](https://github.com/code-yeongyu/pi-websearch) at `06e3ec457e86d299c20808954e18f20b23cc7a64`.

## Senpi adaptations vs upstream

- Imports rewritten manually for the senpi source tree:
- `@mariozechner/pi-tui` -> `@earendil-works/pi-tui`
- `@mariozechner/pi-coding-agent` type/tool imports -> senpi local `../../types.ts` / `../../../types.ts`
- relative `.js` import suffixes -> `.ts`
- No behavior changes versus upstream. The `web_search` tool is registered unconditionally; native provider search bypass remains upstream's provider-based check (`openai` / `anthropic`) so Anthropic-protocol third-party providers such as `kimi-coding` can still use the provider-backed tool.
- Senpi forwards the tool `AbortSignal` into native route discovery so cancellation stops waiting for pending authentication before any provider request begins, and canonicalizes one permitted terminal DNS dot in route identity so dotted and undotted aliases share one candidate. Otherwise behavior matches upstream: the `web_search` tool is registered unconditionally, and native provider search bypass remains upstream's provider-based check (`openai` / `anthropic`) so Anthropic-protocol third-party providers such as `kimi-coding` can still use the provider-backed tool.

## Conflict zones

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHash } from "node:crypto";

import { isAllowedProviderBaseUrl } from "./provider-endpoints.ts";
import type { SearchProvider, SearchProviderEntry } from "./types.ts";

Expand All @@ -21,6 +23,11 @@ interface NativeProviderMapping {
resource: string;
}

interface NativeEntryOptions {
id?: string;
signal?: AbortSignal;
}

function nativeMapping(model: NativeModelInfo): NativeProviderMapping | null {
if (
model.provider === "openai" &&
Expand Down Expand Up @@ -65,26 +72,72 @@ function nativeMapping(model: NativeModelInfo): NativeProviderMapping | null {
}

function buildEndpointUrl(baseUrl: string, resource: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
let configured: URL;
try {
configured = new URL(baseUrl);
} catch {
return baseUrl;
}
const trimmedPath = configured.pathname.replace(/\/+$/, "");
const resourceSlash = `/${resource}`;
if (trimmed.endsWith(resourceSlash)) return trimmed;
if (/\/v\d+$/.test(trimmed)) return `${trimmed}${resourceSlash}`;
return `${trimmed}/v1${resourceSlash}`;
if (trimmedPath.endsWith(resourceSlash)) {
configured.pathname = trimmedPath;
} else if (/\/v\d+$/.test(trimmedPath)) {
configured.pathname = `${trimmedPath}${resourceSlash}`;
} else {
configured.pathname = `${trimmedPath}/v1${resourceSlash}`;
}
configured.hash = "";
return configured.href;
}

export async function buildNativeEntry(
function nativeRouteKey(model: NativeModelInfo): string | null {
const mapping = nativeMapping(model);
if (!mapping) return null;
const baseUrl = buildEndpointUrl(model.baseUrl, mapping.resource);
if (!isAllowedProviderBaseUrl(baseUrl)) return null;
const routeUrl = new URL(baseUrl);
routeUrl.hostname = routeUrl.hostname.replace(/\.$/, "");
return `${mapping.provider}|${routeUrl.href}`;
}

function discoveredNativeEntryId(provider: SearchProvider, routeKey: string): string {
const routeFingerprint = createHash("sha256").update(routeKey).digest("hex").slice(0, 16);
return `native-${provider}-${routeFingerprint}`;
}

async function buildNativeEntryForModel(
model: NativeModelInfo | undefined,
modelRegistry: NativeModelRegistry | undefined,
id = "native",
options: NativeEntryOptions = {},
): Promise<SearchProviderEntry | null> {
if (!model || !modelRegistry) return null;
const { id = "native", signal } = options;

const mapping = nativeMapping(model);
if (!mapping) return null;
const baseUrl = buildEndpointUrl(model.baseUrl, mapping.resource);
if (!isAllowedProviderBaseUrl(baseUrl)) return null;

const auth = await modelRegistry.getApiKeyAndHeaders(model);
signal?.throwIfAborted();
const authPromise = modelRegistry.getApiKeyAndHeaders(model);
let auth: NativeAuthResult;
if (!signal) {
auth = await authPromise;
} else {
signal.throwIfAborted();
let removeAbortListener = (): void => {};
const abortPromise = new Promise<never>((_resolve, reject) => {
const onAbort = (): void => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
});
try {
auth = await Promise.race([authPromise, abortPromise]);
} finally {
removeAbortListener();
}
}
if (!auth.ok || !auth.apiKey) return null;

return {
Expand All @@ -96,43 +149,45 @@ export async function buildNativeEntry(
priority: -1,
};
}

function nativeEntryKey(entry: SearchProviderEntry): string {
return `${entry.provider}:${entry.baseUrl ?? ""}:${entry.model ?? ""}`;
}

function stableIdPart(value: string): string {
return (
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "model"
);
}

function discoveredNativeEntryId(entry: SearchProviderEntry): string {
return `native-${entry.provider}-${stableIdPart(entry.model ?? "model")}`;
}

export async function buildNativeEntries(
model: NativeModelInfo | undefined,
modelRegistry: NativeModelRegistry | undefined,
signal?: AbortSignal,
): Promise<SearchProviderEntry[]> {
signal?.throwIfAborted();
if (!modelRegistry) return [];

const entries: SearchProviderEntry[] = [];
const activeEntry = await buildNativeEntry(model, modelRegistry);
if (activeEntry) entries.push(activeEntry);
const seenRoutes = new Set<string>();

const activeRouteKey = model ? nativeRouteKey(model) : null;
if (activeRouteKey) {
seenRoutes.add(activeRouteKey);
const activeEntry = await buildNativeEntryForModel(model, modelRegistry, { signal });
if (activeEntry) entries.push(activeEntry);
}

if (!modelRegistry.getAvailable) return entries;

const seen = new Set(entries.map(nativeEntryKey));
const availableModels = modelRegistry.getAvailable?.() ?? [];
for (const availableModel of availableModels) {
const entry = await buildNativeEntry(availableModel, modelRegistry, "native-discovered");
for (const availableModel of modelRegistry.getAvailable()) {
const routeKey = nativeRouteKey(availableModel);
if (!routeKey || seenRoutes.has(routeKey)) continue;
seenRoutes.add(routeKey);
const entry = await buildNativeEntryForModel(availableModel, modelRegistry, {
id: "native-discovered",
signal,
});
if (!entry) continue;
const key = nativeEntryKey(entry);
if (seen.has(key)) continue;
seen.add(key);
entries.push({ ...entry, id: discoveredNativeEntryId(entry) });
entries.push({ ...entry, id: discoveredNativeEntryId(entry.provider, routeKey) });
}

return entries;
}

export async function buildNativeEntry(
model: NativeModelInfo | undefined,
modelRegistry: NativeModelRegistry | undefined,
id = "native",
): Promise<SearchProviderEntry | null> {
return buildNativeEntryForModel(model, modelRegistry, { id });
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function isPrivateIpv4(hostname: string): boolean {
}

function isPrivateHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
const normalized = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
return (
normalized === "localhost" ||
normalized.endsWith(".localhost") ||
Expand All @@ -55,6 +55,7 @@ export function isAllowedProviderBaseUrl(baseUrl: string): boolean {
configured.protocol === "https:" &&
configured.username === "" &&
configured.password === "" &&
!configured.hostname.endsWith("..") &&
!isPrivateHostname(configured.hostname)
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,13 @@ interface WebSearchToolContext {
modelRegistry: NativeModelRegistry;
}

async function configWithNativeRoute(config: WebsearchConfig, ctx?: WebSearchToolContext): Promise<WebsearchConfig> {
async function configWithNativeRoute(
config: WebsearchConfig,
ctx: WebSearchToolContext | undefined,
signal: AbortSignal | undefined,
): Promise<WebsearchConfig> {
if (!config.auto) return config;
const nativeEntries = await buildNativeEntries(ctx?.model, ctx?.modelRegistry);
const nativeEntries = await buildNativeEntries(ctx?.model, ctx?.modelRegistry, signal);
return nativeEntries.length > 0 ? { ...config, providers: [...nativeEntries, ...config.providers] } : config;
}

Expand Down Expand Up @@ -78,7 +82,7 @@ export function createWebSearchTool(getConfig: ConfigProvider): WebSearchTool {
}

const maxResults = loaded.config.providers[0]?.maxResults ?? 10;
const config = await configWithNativeRoute(loaded.config, ctx);
const config = await configWithNativeRoute(loaded.config, ctx, signal);
const progressDetails: SearchProgressDetails = {
phase: "searching",
query: params.query,
Expand Down
Loading
Loading