test: add web search config loader

This commit is contained in:
alex wiesner
2026-04-09 11:07:12 +01:00
parent 775fbf7c02
commit 5e1315a20a
5 changed files with 1739 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
{
"name": "pi-web-search-extension",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"test": "tsx --test src/**/*.test.ts"
},
"pi": {
"extensions": [
"./index.ts"
]
},
"dependencies": {
"@sinclair/typebox": "^0.34.49",
"exa-js": "^2.11.0"
},
"devDependencies": {
"@mariozechner/pi-coding-agent": "^0.66.1",
"@mariozechner/pi-tui": "^0.66.1",
"@types/node": "^25.5.2",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
}
}

View File

@@ -0,0 +1,71 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadWebSearchConfig, WebSearchConfigError } from "./config.ts";
async function writeTempConfig(contents: unknown) {
const dir = await mkdtemp(join(tmpdir(), "pi-web-search-config-"));
const file = join(dir, "web-search.json");
const body = typeof contents === "string" ? contents : JSON.stringify(contents, null, 2);
await writeFile(file, body, "utf8");
return file;
}
test("loadWebSearchConfig returns a normalized default provider and provider lookup", async () => {
const file = await writeTempConfig({
defaultProvider: "exa-main",
providers: [
{
name: "exa-main",
type: "exa",
apiKey: "exa-test-key",
options: {
defaultSearchLimit: 7,
defaultFetchTextMaxCharacters: 9000,
},
},
],
});
const config = await loadWebSearchConfig(file);
assert.equal(config.defaultProviderName, "exa-main");
assert.equal(config.defaultProvider.name, "exa-main");
assert.equal(config.providersByName.get("exa-main")?.apiKey, "exa-test-key");
assert.equal(config.providers[0]?.options?.defaultSearchLimit, 7);
});
test("loadWebSearchConfig rejects a missing default provider target", async () => {
const file = await writeTempConfig({
defaultProvider: "missing",
providers: [
{
name: "exa-main",
type: "exa",
apiKey: "exa-test-key",
},
],
});
await assert.rejects(
() => loadWebSearchConfig(file),
(error) =>
error instanceof WebSearchConfigError &&
/defaultProvider \"missing\"/.test(error.message),
);
});
test("loadWebSearchConfig rejects a missing file with a helpful example message", async () => {
const file = join(tmpdir(), "pi-web-search-does-not-exist.json");
await assert.rejects(
() => loadWebSearchConfig(file),
(error) =>
error instanceof WebSearchConfigError &&
error.message.includes(file) &&
error.message.includes('"defaultProvider"') &&
error.message.includes('"providers"'),
);
});

View File

@@ -0,0 +1,100 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { Value } from "@sinclair/typebox/value";
import { WebSearchConfigSchema, type ExaProviderConfig, type WebSearchConfig } from "./schema.ts";
export interface ResolvedWebSearchConfig {
path: string;
defaultProviderName: string;
defaultProvider: ExaProviderConfig;
providers: ExaProviderConfig[];
providersByName: Map<string, ExaProviderConfig>;
}
export class WebSearchConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "WebSearchConfigError";
}
}
export function getDefaultWebSearchConfigPath() {
return join(homedir(), ".pi", "agent", "web-search.json");
}
function exampleConfigSnippet() {
return JSON.stringify(
{
defaultProvider: "exa-main",
providers: [
{
name: "exa-main",
type: "exa",
apiKey: "exa_...",
},
],
},
null,
2,
);
}
export function normalizeWebSearchConfig(config: WebSearchConfig, path: string): ResolvedWebSearchConfig {
const providersByName = new Map<string, ExaProviderConfig>();
for (const provider of config.providers) {
if (!provider.apiKey.trim()) {
throw new WebSearchConfigError(`Provider \"${provider.name}\" in ${path} is missing a literal apiKey.`);
}
if (providersByName.has(provider.name)) {
throw new WebSearchConfigError(`Duplicate provider name \"${provider.name}\" in ${path}.`);
}
providersByName.set(provider.name, provider);
}
const defaultProvider = providersByName.get(config.defaultProvider);
if (!defaultProvider) {
throw new WebSearchConfigError(
`defaultProvider \"${config.defaultProvider}\" does not match any configured provider in ${path}.`,
);
}
return {
path,
defaultProviderName: config.defaultProvider,
defaultProvider,
providers: [...providersByName.values()],
providersByName,
};
}
export async function loadWebSearchConfig(path = getDefaultWebSearchConfigPath()) {
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new WebSearchConfigError(
`Missing web-search config at ${path}.\nCreate ${path} with contents like:\n${exampleConfigSnippet()}`,
);
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new WebSearchConfigError(`Invalid JSON in ${path}: ${(error as Error).message}`);
}
if (!Value.Check(WebSearchConfigSchema, parsed)) {
const [firstError] = [...Value.Errors(WebSearchConfigSchema, parsed)];
throw new WebSearchConfigError(
`Invalid web-search config at ${path}: ${firstError?.path ?? "/"} ${firstError?.message ?? "failed validation"}`,
);
}
return normalizeWebSearchConfig(parsed as WebSearchConfig, path);
}

View File

@@ -0,0 +1,45 @@
import { Type, type Static } from "@sinclair/typebox";
export const ProviderOptionsSchema = Type.Object({
defaultSearchLimit: Type.Optional(Type.Integer({ minimum: 1 })),
defaultFetchTextMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })),
defaultFetchHighlightsMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })),
});
export const ExaProviderConfigSchema = Type.Object({
name: Type.String({ minLength: 1 }),
type: Type.Literal("exa"),
apiKey: Type.String({ minLength: 1 }),
options: Type.Optional(ProviderOptionsSchema),
});
export const WebSearchConfigSchema = Type.Object({
defaultProvider: Type.String({ minLength: 1 }),
providers: Type.Array(ExaProviderConfigSchema, { minItems: 1 }),
});
export const WebSearchParamsSchema = Type.Object({
query: Type.String({ minLength: 1, description: "Search query" }),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 25 })),
includeDomains: Type.Optional(Type.Array(Type.String())),
excludeDomains: Type.Optional(Type.Array(Type.String())),
startPublishedDate: Type.Optional(Type.String()),
endPublishedDate: Type.Optional(Type.String()),
category: Type.Optional(Type.String()),
provider: Type.Optional(Type.String()),
});
export const WebFetchParamsSchema = Type.Object({
urls: Type.Array(Type.String(), { minItems: 1 }),
text: Type.Optional(Type.Boolean()),
highlights: Type.Optional(Type.Boolean()),
summary: Type.Optional(Type.Boolean()),
textMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })),
provider: Type.Optional(Type.String()),
});
export type ProviderOptions = Static<typeof ProviderOptionsSchema>;
export type ExaProviderConfig = Static<typeof ExaProviderConfigSchema>;
export type WebSearchConfig = Static<typeof WebSearchConfigSchema>;
export type WebSearchParams = Static<typeof WebSearchParamsSchema>;
export type WebFetchParams = Static<typeof WebFetchParamsSchema>;

File diff suppressed because it is too large Load Diff