Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/web/codex.ts

Raw
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { SearchResult } from "./constants.js";

const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
const DEFAULT_MODEL = "gpt-5.5";
const MAX_SOURCES = 10;
const JWT_CLAIM_PATH = "https://api.openai.com/auth";

interface CodexSearchSource {
	title: string;
	url: string;
	snippet: string;
}

interface CodexSearchOutput {
	summary?: string;
	sources?: CodexSearchSource[];
}

function isRecord(value: unknown): value is Record<string, unknown> {
	return typeof value === "object" && value !== null && !Array.isArray(value);
}

function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
	try {
		const payload = token.split(".")[1];
		if (!payload) return undefined;
		return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
	} catch {
		return undefined;
	}
}

function accountIdFromToken(token: string): string | undefined {
	const payload = decodeJwtPayload(token);
	const auth = payload?.[JWT_CLAIM_PATH];
	if (!isRecord(auth)) return undefined;
	return typeof auth.chatgpt_account_id === "string"
		? auth.chatgpt_account_id
		: undefined;
}

async function codexAuth(
	ctx: ExtensionContext,
): Promise<{ accessToken: string; accountId: string } | undefined> {
	const accessToken = await ctx.modelRegistry
		.getApiKeyForProvider("openai-codex")
		.catch(() => undefined);
	if (!accessToken) return undefined;
	const accountId = accountIdFromToken(accessToken);
	return accountId ? { accessToken, accountId } : undefined;
}

export async function isCodexAvailable(
	ctx?: ExtensionContext,
): Promise<boolean> {
	if (!ctx) return false;
	return (await codexAuth(ctx)) !== undefined;
}

function schema(): string {
	return JSON.stringify({
		type: "object",
		additionalProperties: false,
		properties: {
			summary: { type: "string" },
			sources: {
				type: "array",
				items: {
					type: "object",
					additionalProperties: false,
					properties: {
						title: { type: "string" },
						url: { type: "string" },
						snippet: { type: "string" },
					},
					required: ["title", "url", "snippet"],
				},
			},
		},
		required: ["summary", "sources"],
	});
}

function buildPrompt(query: string, count: number): string {
	return [
		"Search the public web for the user's query.",
		"Return ONLY JSON matching this schema:",
		schema(),
		"Do not wrap JSON in markdown fences. Do not add commentary.",
		`Limit sources to at most ${count}. Prefer official or primary sources.`,
		"Keep snippets short and directly relevant.",
		"",
		`Query: ${query}`,
	].join("\n");
}

function parseSseBlock(
	block: string,
): { type: string; data: Record<string, unknown> } | undefined {
	let eventType = "";
	const dataLines: string[] = [];

	for (const raw of block.split("\n")) {
		const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
		if (line.startsWith("event: ")) eventType = line.slice(7).trim();
		else if (line.startsWith("data: ")) dataLines.push(line.slice(6));
		else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
	}

	const text = dataLines.join("\n").trim();
	if (!text || text === "[DONE]") return undefined;

	try {
		const data = JSON.parse(text) as Record<string, unknown>;
		const type = eventType || (typeof data.type === "string" ? data.type : "");
		return type ? { type, data } : undefined;
	} catch {
		return undefined;
	}
}

async function readOutputText(
	response: Response,
	signal?: AbortSignal,
): Promise<string> {
	if (!response.body) throw new Error("Codex search response has no body.");
	const reader = response.body.getReader();
	const decoder = new TextDecoder();
	let buffer = "";
	let output = "";

	try {
		while (true) {
			if (signal?.aborted) throw new Error("Codex search aborted.");
			const { value, done } = await reader.read();
			if (done) break;
			buffer += decoder.decode(value, { stream: true });

			let idx = buffer.search(/\r?\n\r?\n/);
			while (idx >= 0) {
				const block = buffer.slice(0, idx);
				const sep = buffer.slice(idx).match(/^\r?\n\r?\n/)?.[0] ?? "\n\n";
				buffer = buffer.slice(idx + sep.length);
				const event = parseSseBlock(block);
				if (
					event?.type === "response.output_text.delta" &&
					typeof event.data.delta === "string"
				) {
					output += event.data.delta;
				}
				idx = buffer.search(/\r?\n\r?\n/);
			}
		}

		const trailing = parseSseBlock(buffer.trim());
		if (
			trailing?.type === "response.output_text.delta" &&
			typeof trailing.data.delta === "string"
		) {
			output += trailing.data.delta;
		}
	} finally {
		reader.releaseLock();
	}

	return output;
}

function asResults(parsed: CodexSearchOutput, count: number): SearchResult[] {
	const results = (parsed.sources ?? []).slice(0, count).map((source) => ({
		title: source.title,
		url: source.url,
		snippet: source.snippet,
	}));

	if (results.length > 0 || !parsed.summary?.trim()) return results;
	return [
		{ title: "Codex search summary", url: "", snippet: parsed.summary.trim() },
	];
}

export async function codexSearch(
	query: string,
	ctx: ExtensionContext,
	signal?: AbortSignal,
	count = 5,
): Promise<SearchResult[]> {
	const auth = await codexAuth(ctx);
	if (!auth) throw new Error("OpenAI Codex auth unavailable.");

	const maxSources = Math.min(Math.max(Math.trunc(count), 1), MAX_SOURCES);
	const response = await fetch(CODEX_API_ENDPOINT, {
		method: "POST",
		headers: {
			"Content-Type": "application/json",
			Authorization: `Bearer ${auth.accessToken}`,
			"ChatGPT-Account-ID": auth.accountId,
			accept: "text/event-stream",
		},
		body: JSON.stringify({
			model: DEFAULT_MODEL,
			instructions: buildPrompt(query, maxSources),
			input: [{ role: "user", content: `Search the web for: ${query}` }],
			tools: [{ type: "web_search" }],
			store: false,
			stream: true,
		}),
		signal,
	});

	if (!response.ok) {
		const text = await response.text().catch(() => "");
		throw new Error(`Codex search ${response.status}: ${text.slice(0, 200)}`);
	}

	const raw = (await readOutputText(response, signal)).trim();
	if (!raw) throw new Error("Codex search returned no output.");

	const parsed = JSON.parse(raw) as CodexSearchOutput;
	if (!Array.isArray(parsed.sources))
		throw new Error("Codex search returned invalid JSON.");
	return asResults(parsed, maxSources);
}

export const _test = { accountIdFromToken, parseSseBlock };