pi-codex-search 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/index.ts +163 -0
  4. package/package.json +68 -0
  5. package/src/codex.ts +396 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # pi-codex-search
2
+
3
+ [![npm](https://img.shields.io/npm/v/pi-codex-search)](https://www.npmjs.com/package/pi-codex-search)
4
+ [![license](https://img.shields.io/npm/l/pi-codex-search)](./LICENSE)
5
+
6
+ Pi extension that adds a `web_search` tool backed by the ChatGPT Codex backend.
7
+
8
+ It reuses the `openai-codex` subscription already configured in pi-coding-agent. The extension does not read `ACCESS_TOKEN` during normal pi usage and does not start a separate login flow.
9
+
10
+ ## Install
11
+
12
+ Local checkout:
13
+
14
+ ```bash
15
+ pi -e /path/to/pi-codex-search
16
+ ```
17
+
18
+ After publishing, the package can be installed through pi's npm package path:
19
+
20
+ ```bash
21
+ pi install npm:pi-codex-search
22
+ ```
23
+
24
+ The package manifest exposes the extension through:
25
+
26
+ ```json
27
+ {
28
+ "pi": {
29
+ "extensions": ["./index.ts"]
30
+ }
31
+ }
32
+ ```
33
+
34
+ ## Authentication
35
+
36
+ Inside `pi`, run:
37
+
38
+ ```text
39
+ /login openai-codex
40
+ ```
41
+
42
+ Choose `ChatGPT Plus/Pro (Codex Subscription)` if pi prompts for a provider. Credentials are stored in pi's auth file and refreshed by pi.
43
+
44
+ At `session_start`, this extension calls `ctx.modelRegistry.getApiKeyForProvider("openai-codex")`. If no token is available, or if the account id cannot be found from the stored OAuth credential or decoded access token, it does not register `web_search`. In that case the model will not see the tool.
45
+
46
+ ## Usage
47
+
48
+ When Codex auth is available at session start, the extension registers:
49
+
50
+ ```json
51
+ {
52
+ "name": "web_search",
53
+ "arguments": {
54
+ "query": "latest OpenAI Codex release notes",
55
+ "search_context_size": "medium",
56
+ "live": true
57
+ }
58
+ }
59
+ ```
60
+
61
+ Parameters:
62
+
63
+ - `query`: required search question.
64
+ - `search_context_size`: optional, one of `low`, `medium`, `high`.
65
+ - `live`: optional boolean. Defaults to live web access.
66
+
67
+ Model selection:
68
+
69
+ - If `PI_CODEX_WEB_SEARCH_MODEL` is set, that model id is used.
70
+ - Otherwise, if the active pi model provider is `openai-codex`, the active model id is used.
71
+ - Otherwise, the extension fetches `/codex/models?client_version=...` and uses the default model from that response.
72
+
73
+ The tool returns text content. Its structured `details` include:
74
+
75
+ - `model`
76
+ - `responseId`
77
+ - `searchCalls`
78
+ - `citations`
79
+ - `usage`
80
+
81
+ ## Configuration
82
+
83
+ - `PI_CODEX_WEB_SEARCH_MODEL`: override the Codex model used by the tool.
84
+ - `PI_CODEX_WEB_SEARCH_BASE_URL`: override the Codex backend base URL.
85
+ - `PI_CODEX_WEB_SEARCH_CLIENT_VERSION`: override the `client_version` sent to `/codex/models`.
86
+ - `PI_CODEX_WEB_SEARCH_CONTEXT_SIZE`: default search context size: `low`, `medium`, or `high`.
87
+ - `PI_CODEX_WEB_SEARCH_LIVE`: set to `false` to use cached web access.
88
+
89
+ The request headers are built by the extension. They include `Authorization`, `chatgpt-account-id`, `originator: pi`, `OpenAI-Beta: responses=experimental`, `accept`, and `content-type` for streaming response requests.
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ npm install
95
+ npm run check
96
+ npm test
97
+ npm run lint
98
+ npm run format:check
99
+ ```
100
+
101
+ ## Release
102
+
103
+ This repository follows the same release shape as `pi-provider-kimi-code`:
104
+
105
+ - `release-naming.env` defines `PKG_NAME=pi-codex-search` and `TAG_PREFIX=v`.
106
+ - `scripts/next-version.sh` computes the next semantic version from tags.
107
+ - `.github/workflows/release-command.yml` creates release commits and tags.
108
+ - `.github/workflows/release-on-tag.yml` publishes to npm with provenance and attaches `pi-codex-search.tar.gz` to the GitHub release.
109
+
110
+ GitHub release tarball installs from the extracted directory:
111
+
112
+ ```bash
113
+ curl -L https://github.com/Leechael/pi-codex-search/releases/latest/download/pi-codex-search.tar.gz | tar -xz -C /tmp
114
+ pi install /tmp/pi-codex-search
115
+ ```
116
+
117
+ ## References
118
+
119
+ - Upstream harness: [earendil-works/pi](https://github.com/earendil-works/pi) · [pi-coding-agent](https://github.com/earendil-works/pi/tree/main/packages/coding-agent)
120
+
121
+ ## License
122
+
123
+ MIT
package/index.ts ADDED
@@ -0,0 +1,163 @@
1
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
2
+ import {
3
+ defineTool,
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ extractAccountIdFromToken,
9
+ fetchCodexModels,
10
+ fetchCodexWebSearch,
11
+ selectDefaultModel,
12
+ type SearchContextSize,
13
+ } from "./src/codex.ts";
14
+
15
+ const OPENAI_CODEX_PROVIDER = "openai-codex";
16
+ const DEFAULT_CONTEXT_SIZE = "medium";
17
+
18
+ const webSearchTool = defineTool({
19
+ name: "web_search",
20
+ label: "Web Search",
21
+ description:
22
+ "Search the web using the user's configured ChatGPT Codex subscription and return an answer with sources.",
23
+ promptSnippet: "web_search: search the web using the configured ChatGPT Codex subscription.",
24
+ promptGuidelines: [
25
+ "Use web_search when current or source-backed information is needed.",
26
+ "Do not ask the user for an access token; the tool uses pi's configured OpenAI Codex subscription.",
27
+ ],
28
+ parameters: Type.Object({
29
+ query: Type.String({ description: "The web search question to answer." }),
30
+ search_context_size: Type.Optional(
31
+ StringEnum(["low", "medium", "high"] as const, {
32
+ description: "Amount of web context to retrieve. Defaults to medium.",
33
+ }),
34
+ ),
35
+ live: Type.Optional(Type.Boolean({ description: "Use live web access. Defaults to true." })),
36
+ }),
37
+
38
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
39
+ const query = params.query.trim();
40
+ if (!query) {
41
+ throw new Error("query must not be empty");
42
+ }
43
+
44
+ const token = await ctx.modelRegistry.getApiKeyForProvider(OPENAI_CODEX_PROVIDER);
45
+ if (!token) {
46
+ throw new Error(
47
+ "OpenAI Codex subscription is not configured. Run /login and choose ChatGPT Plus/Pro.",
48
+ );
49
+ }
50
+
51
+ const accountId = getConfiguredAccountId(ctx, token);
52
+ if (!accountId) {
53
+ throw new Error(
54
+ "OpenAI Codex account id was not found in stored credentials or access token.",
55
+ );
56
+ }
57
+
58
+ const baseUrl = process.env.PI_CODEX_WEB_SEARCH_BASE_URL;
59
+ const model = await resolveSearchModel(ctx, token, accountId, baseUrl, signal);
60
+ let streamedText = "";
61
+
62
+ const result = await fetchCodexWebSearch({
63
+ query,
64
+ token,
65
+ accountId,
66
+ model,
67
+ baseUrl,
68
+ externalWebAccess: resolveLive(params.live),
69
+ searchContextSize: resolveSearchContextSize(params.search_context_size),
70
+ signal,
71
+ onTextDelta: (delta) => {
72
+ streamedText += delta;
73
+ onUpdate?.({
74
+ content: [{ type: "text", text: streamedText }],
75
+ details: { model, partial: true },
76
+ });
77
+ },
78
+ });
79
+
80
+ return {
81
+ content: [{ type: "text", text: formatToolText(result.text, result.citations) }],
82
+ details: {
83
+ model: result.model,
84
+ responseId: result.responseId,
85
+ searchCalls: result.searchCalls,
86
+ citations: result.citations,
87
+ usage: result.usage,
88
+ },
89
+ };
90
+ },
91
+ });
92
+
93
+ export default function codexWebSearchExtension(pi: ExtensionAPI) {
94
+ let registered = false;
95
+
96
+ pi.on("session_start", async (_event, ctx) => {
97
+ if (registered) return;
98
+
99
+ const token = await ctx.modelRegistry.getApiKeyForProvider(OPENAI_CODEX_PROVIDER);
100
+ if (!token || !getConfiguredAccountId(ctx, token)) {
101
+ return;
102
+ }
103
+
104
+ pi.registerTool(webSearchTool);
105
+ registered = true;
106
+ });
107
+ }
108
+
109
+ function getConfiguredAccountId(ctx: ExtensionContext, token: string): string | undefined {
110
+ const credential = ctx.modelRegistry.authStorage.get(OPENAI_CODEX_PROVIDER);
111
+ if (credential?.type === "oauth" && typeof credential.accountId === "string") {
112
+ return credential.accountId;
113
+ }
114
+ return extractAccountIdFromToken(token);
115
+ }
116
+
117
+ async function resolveSearchModel(
118
+ ctx: ExtensionContext,
119
+ token: string,
120
+ accountId: string,
121
+ baseUrl: string | undefined,
122
+ signal: AbortSignal | undefined,
123
+ ): Promise<string> {
124
+ const override = process.env.PI_CODEX_WEB_SEARCH_MODEL?.trim();
125
+ if (override) return override;
126
+ if (ctx.model?.provider === OPENAI_CODEX_PROVIDER) return ctx.model.id;
127
+
128
+ const models = await fetchCodexModels({
129
+ token,
130
+ accountId,
131
+ baseUrl,
132
+ clientVersion: process.env.PI_CODEX_WEB_SEARCH_CLIENT_VERSION,
133
+ signal,
134
+ });
135
+ const model = selectDefaultModel(models);
136
+ if (!model) {
137
+ throw new Error("Codex model list is empty.");
138
+ }
139
+ return model;
140
+ }
141
+
142
+ function resolveSearchContextSize(value: string | undefined): SearchContextSize {
143
+ const configured = value ?? process.env.PI_CODEX_WEB_SEARCH_CONTEXT_SIZE ?? DEFAULT_CONTEXT_SIZE;
144
+ if (configured === "low" || configured === "medium" || configured === "high") {
145
+ return configured;
146
+ }
147
+ throw new Error(`Invalid search_context_size: ${configured}`);
148
+ }
149
+
150
+ function resolveLive(value: boolean | undefined): boolean {
151
+ if (value !== undefined) return value;
152
+ return process.env.PI_CODEX_WEB_SEARCH_LIVE !== "false";
153
+ }
154
+
155
+ function formatToolText(text: string, citations: Array<{ title?: string; url: string }>): string {
156
+ if (citations.length === 0) return text || "(no response text)";
157
+
158
+ const sourceLines = citations.map((citation, index) => {
159
+ const title = citation.title?.trim() || citation.url;
160
+ return `${index + 1}. ${title}: ${citation.url}`;
161
+ });
162
+ return `${text || "(no response text)"}\n\nSources:\n${sourceLines.join("\n")}`;
163
+ }
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "pi-codex-search",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension for Codex Web Search — reuse your ChatGPT Plus/Pro Codex subscription in pi-coding-agent",
5
+ "keywords": [
6
+ "chatgpt",
7
+ "chatgpt-plus",
8
+ "chatgpt-pro",
9
+ "codex",
10
+ "coding-agent",
11
+ "oauth",
12
+ "openai",
13
+ "openai-codex",
14
+ "pi",
15
+ "pi-coding-agent",
16
+ "pi-extension",
17
+ "pi-mono",
18
+ "pi-package",
19
+ "responses-api",
20
+ "subscription",
21
+ "tool-extension",
22
+ "web-search",
23
+ "web_search"
24
+ ],
25
+ "bugs": {
26
+ "url": "https://github.com/Leechael/pi-codex-search/issues"
27
+ },
28
+ "license": "MIT",
29
+ "author": "Leechael",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/Leechael/pi-codex-search"
33
+ },
34
+ "files": [
35
+ "index.ts",
36
+ "src",
37
+ "package.json",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "type": "module",
42
+ "scripts": {
43
+ "clean": "echo 'nothing to clean'",
44
+ "build": "echo 'nothing to build'",
45
+ "check": "tsc --noEmit",
46
+ "test": "node --test tests/*.test.ts",
47
+ "lint": "oxlint .",
48
+ "format": "oxfmt . --write",
49
+ "format:check": "oxfmt . --check",
50
+ "pre-commit": "prek run"
51
+ },
52
+ "devDependencies": {
53
+ "@j178/prek": "^0.3.8",
54
+ "@types/node": "^24.0.0",
55
+ "oxfmt": "^0.44.0",
56
+ "oxlint": "^1.59.0",
57
+ "typescript": "^5.9.0"
58
+ },
59
+ "peerDependencies": {
60
+ "@earendil-works/pi-ai": "*",
61
+ "@earendil-works/pi-coding-agent": "*"
62
+ },
63
+ "pi": {
64
+ "extensions": [
65
+ "./index.ts"
66
+ ]
67
+ }
68
+ }
package/src/codex.ts ADDED
@@ -0,0 +1,396 @@
1
+ export type SearchContextSize = "low" | "medium" | "high";
2
+
3
+ export interface CodexModel {
4
+ id: string;
5
+ name?: string;
6
+ isDefault?: boolean;
7
+ }
8
+
9
+ export interface CodexWebSearchOptions {
10
+ query: string;
11
+ token: string;
12
+ accountId: string;
13
+ model: string;
14
+ baseUrl?: string;
15
+ externalWebAccess?: boolean;
16
+ searchContextSize?: SearchContextSize;
17
+ signal?: AbortSignal;
18
+ onTextDelta?: (delta: string) => void;
19
+ fetchImpl?: typeof fetch;
20
+ }
21
+
22
+ export interface CodexCitation {
23
+ title?: string;
24
+ url: string;
25
+ startIndex?: number;
26
+ endIndex?: number;
27
+ }
28
+
29
+ export interface CodexSearchCall {
30
+ id?: string;
31
+ status?: string;
32
+ query?: string;
33
+ url?: string;
34
+ actionType?: string;
35
+ }
36
+
37
+ export interface CodexWebSearchResult {
38
+ responseId?: string;
39
+ model: string;
40
+ text: string;
41
+ searchCalls: CodexSearchCall[];
42
+ citations: CodexCitation[];
43
+ usage?: {
44
+ inputTokens?: number;
45
+ outputTokens?: number;
46
+ totalTokens?: number;
47
+ };
48
+ }
49
+
50
+ interface SseEvent {
51
+ type: string;
52
+ data?: unknown;
53
+ raw?: string;
54
+ }
55
+
56
+ interface ResponseOutputText {
57
+ type?: string;
58
+ text?: string;
59
+ annotations?: Array<{
60
+ type?: string;
61
+ title?: string;
62
+ url?: string;
63
+ start_index?: number;
64
+ end_index?: number;
65
+ }>;
66
+ }
67
+
68
+ interface ResponseOutputItem {
69
+ id?: string;
70
+ type?: string;
71
+ status?: string;
72
+ role?: string;
73
+ action?: {
74
+ type?: string;
75
+ query?: string;
76
+ queries?: string[];
77
+ url?: string;
78
+ };
79
+ content?: ResponseOutputText[];
80
+ }
81
+
82
+ interface ResponseUsage {
83
+ input_tokens?: number;
84
+ output_tokens?: number;
85
+ total_tokens?: number;
86
+ }
87
+
88
+ interface ResponseEnvelope {
89
+ id?: string;
90
+ usage?: ResponseUsage;
91
+ }
92
+
93
+ interface ResponseEventData {
94
+ response?: ResponseEnvelope;
95
+ item?: ResponseOutputItem;
96
+ delta?: string;
97
+ error?: {
98
+ message?: string;
99
+ code?: string;
100
+ };
101
+ }
102
+
103
+ const DEFAULT_BASE_URL = "https://chatgpt.com/backend-api";
104
+ const DEFAULT_CLIENT_VERSION = "1.0.0";
105
+ const ACCOUNT_ID_CLAIM = "https://api.openai.com/auth";
106
+
107
+ export function normalizeCodexBaseUrl(baseUrl: string | undefined): string {
108
+ const raw = baseUrl && baseUrl.trim().length > 0 ? baseUrl : DEFAULT_BASE_URL;
109
+ const normalized = raw.replace(/\/+$/, "");
110
+ if (normalized.endsWith("/codex/responses"))
111
+ return normalized.slice(0, -"/codex/responses".length);
112
+ if (normalized.endsWith("/codex")) return normalized.slice(0, -"/codex".length);
113
+ return normalized;
114
+ }
115
+
116
+ export function resolveCodexEndpoint(
117
+ baseUrl: string | undefined,
118
+ path: "models" | "responses",
119
+ ): string {
120
+ return `${normalizeCodexBaseUrl(baseUrl)}/codex/${path}`;
121
+ }
122
+
123
+ export function extractAccountIdFromToken(token: string): string | undefined {
124
+ const parts = token.split(".");
125
+ if (parts.length !== 3) return undefined;
126
+
127
+ try {
128
+ const payload = JSON.parse(Buffer.from(parts[1] ?? "", "base64url").toString("utf8")) as {
129
+ [ACCOUNT_ID_CLAIM]?: { chatgpt_account_id?: unknown };
130
+ };
131
+ const accountId = payload[ACCOUNT_ID_CLAIM]?.chatgpt_account_id;
132
+ return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined;
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ export async function fetchCodexModels(options: {
139
+ token: string;
140
+ accountId: string;
141
+ baseUrl?: string;
142
+ clientVersion?: string;
143
+ fetchImpl?: typeof fetch;
144
+ signal?: AbortSignal;
145
+ }): Promise<CodexModel[]> {
146
+ const fetcher = options.fetchImpl ?? fetch;
147
+ const endpoint = new URL(resolveCodexEndpoint(options.baseUrl, "models"));
148
+ endpoint.searchParams.set(
149
+ "client_version",
150
+ options.clientVersion ??
151
+ process.env.PI_CODEX_WEB_SEARCH_CLIENT_VERSION ??
152
+ DEFAULT_CLIENT_VERSION,
153
+ );
154
+
155
+ const response = await fetcher(endpoint.toString(), {
156
+ headers: buildCodexHeaders(options.token, options.accountId, "application/json"),
157
+ signal: options.signal,
158
+ });
159
+ if (!response.ok) {
160
+ throw new Error(
161
+ `Codex models request failed: HTTP ${response.status} ${await response.text()}`,
162
+ );
163
+ }
164
+
165
+ const data = (await response.json()) as {
166
+ models?: Array<{
167
+ slug?: string;
168
+ id?: string;
169
+ model?: string;
170
+ display_name?: string;
171
+ is_default?: boolean;
172
+ }>;
173
+ };
174
+ return (data.models ?? [])
175
+ .map((model) => ({
176
+ id: model.slug ?? model.id ?? model.model ?? "",
177
+ name: model.display_name,
178
+ isDefault: model.is_default,
179
+ }))
180
+ .filter((model) => model.id.length > 0);
181
+ }
182
+
183
+ export function selectDefaultModel(models: CodexModel[]): string | undefined {
184
+ return (models.find((model) => model.isDefault) ?? models[0])?.id;
185
+ }
186
+
187
+ export async function fetchCodexWebSearch(
188
+ options: CodexWebSearchOptions,
189
+ ): Promise<CodexWebSearchResult> {
190
+ const fetcher = options.fetchImpl ?? fetch;
191
+ const response = await fetcher(resolveCodexEndpoint(options.baseUrl, "responses"), {
192
+ method: "POST",
193
+ headers: buildCodexHeaders(options.token, options.accountId, "text/event-stream"),
194
+ body: JSON.stringify(buildWebSearchRequestBody(options)),
195
+ signal: options.signal,
196
+ });
197
+
198
+ if (!response.ok) {
199
+ throw new Error(
200
+ `Codex web search request failed: HTTP ${response.status} ${await response.text()}`,
201
+ );
202
+ }
203
+ if (!response.body) {
204
+ throw new Error("Codex web search response did not include a body");
205
+ }
206
+
207
+ let responseId: string | undefined;
208
+ let usage: ResponseUsage | undefined;
209
+ let streamedText = "";
210
+ const messageTextParts: string[] = [];
211
+ const searchCalls = new Map<string, CodexSearchCall>();
212
+ const citations = new Map<string, CodexCitation>();
213
+
214
+ for await (const event of parseSse(response.body)) {
215
+ const data = event.data as ResponseEventData | undefined;
216
+ if (!data) continue;
217
+
218
+ if (event.type === "response.created") {
219
+ responseId = data.response?.id;
220
+ continue;
221
+ }
222
+
223
+ if (event.type === "response.output_text.delta") {
224
+ const delta = data.delta ?? "";
225
+ streamedText += delta;
226
+ options.onTextDelta?.(delta);
227
+ continue;
228
+ }
229
+
230
+ if (event.type === "response.output_item.added" && data.item?.type === "web_search_call") {
231
+ const item = data.item;
232
+ searchCalls.set(item.id ?? `search-${searchCalls.size + 1}`, {
233
+ id: item.id,
234
+ status: item.status,
235
+ });
236
+ continue;
237
+ }
238
+
239
+ if (event.type === "response.output_item.done") {
240
+ collectOutputItem(data.item, searchCalls, messageTextParts, citations);
241
+ continue;
242
+ }
243
+
244
+ if (event.type === "response.completed") {
245
+ usage = data.response?.usage;
246
+ continue;
247
+ }
248
+
249
+ if (event.type === "response.failed") {
250
+ throw new Error(data.error?.message ?? data.error?.code ?? "Codex web search failed");
251
+ }
252
+ }
253
+
254
+ return {
255
+ responseId,
256
+ model: options.model,
257
+ text: messageTextParts.join("") || streamedText,
258
+ searchCalls: [...searchCalls.values()],
259
+ citations: [...citations.values()],
260
+ usage: usage
261
+ ? {
262
+ inputTokens: usage.input_tokens,
263
+ outputTokens: usage.output_tokens,
264
+ totalTokens: usage.total_tokens,
265
+ }
266
+ : undefined,
267
+ };
268
+ }
269
+
270
+ function buildCodexHeaders(token: string, accountId: string, accept: string): Headers {
271
+ const headers = new Headers();
272
+ headers.set("Authorization", `Bearer ${token}`);
273
+ headers.set("chatgpt-account-id", accountId);
274
+ headers.set("originator", "pi");
275
+ headers.set("OpenAI-Beta", "responses=experimental");
276
+ headers.set("accept", accept);
277
+ if (accept === "text/event-stream") {
278
+ headers.set("content-type", "application/json");
279
+ }
280
+ headers.set("User-Agent", "pi-codex-search");
281
+ return headers;
282
+ }
283
+
284
+ function buildWebSearchRequestBody(options: CodexWebSearchOptions) {
285
+ return {
286
+ model: options.model,
287
+ instructions:
288
+ "You are a concise web search assistant. Use web search, answer the query, and preserve source citations from annotations.",
289
+ input: [
290
+ {
291
+ type: "message",
292
+ role: "user",
293
+ content: [{ type: "input_text", text: options.query }],
294
+ },
295
+ ],
296
+ tools: [
297
+ {
298
+ type: "web_search",
299
+ external_web_access: options.externalWebAccess ?? true,
300
+ search_context_size: options.searchContextSize ?? "medium",
301
+ },
302
+ ],
303
+ tool_choice: "required",
304
+ parallel_tool_calls: true,
305
+ store: false,
306
+ stream: true,
307
+ include: [],
308
+ };
309
+ }
310
+
311
+ async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent> {
312
+ const reader = body.getReader();
313
+ const decoder = new TextDecoder();
314
+ let buffer = "";
315
+
316
+ while (true) {
317
+ const { done, value } = await reader.read();
318
+ if (done) break;
319
+ buffer += decoder.decode(value, { stream: true });
320
+
321
+ let separatorIndex = buffer.indexOf("\n\n");
322
+ while (separatorIndex !== -1) {
323
+ const frame = buffer.slice(0, separatorIndex);
324
+ buffer = buffer.slice(separatorIndex + 2);
325
+ const event = parseSseFrame(frame);
326
+ if (event) yield event;
327
+ separatorIndex = buffer.indexOf("\n\n");
328
+ }
329
+ }
330
+
331
+ buffer += decoder.decode();
332
+ const event = parseSseFrame(buffer);
333
+ if (event) yield event;
334
+ }
335
+
336
+ function parseSseFrame(frame: string): SseEvent | undefined {
337
+ const lines = frame.split(/\r?\n/);
338
+ let type = "";
339
+ const dataLines: string[] = [];
340
+
341
+ for (const line of lines) {
342
+ if (line.startsWith("event:")) {
343
+ type = line.slice("event:".length).trim();
344
+ } else if (line.startsWith("data:")) {
345
+ dataLines.push(line.slice("data:".length).trimStart());
346
+ }
347
+ }
348
+
349
+ if (dataLines.length === 0) return undefined;
350
+ const raw = dataLines.join("\n");
351
+ if (raw === "[DONE]") return undefined;
352
+
353
+ try {
354
+ return { type, data: JSON.parse(raw) };
355
+ } catch {
356
+ return { type, raw };
357
+ }
358
+ }
359
+
360
+ function collectOutputItem(
361
+ item: ResponseOutputItem | undefined,
362
+ searchCalls: Map<string, CodexSearchCall>,
363
+ messageTextParts: string[],
364
+ citations: Map<string, CodexCitation>,
365
+ ): void {
366
+ if (!item) return;
367
+
368
+ if (item.type === "web_search_call") {
369
+ const key = item.id ?? `search-${searchCalls.size + 1}`;
370
+ const query = item.action?.query ?? item.action?.queries?.join(", ");
371
+ searchCalls.set(key, {
372
+ id: item.id,
373
+ status: item.status,
374
+ query,
375
+ url: item.action?.url,
376
+ actionType: item.action?.type,
377
+ });
378
+ return;
379
+ }
380
+
381
+ if (item.type !== "message" || item.role !== "assistant") return;
382
+
383
+ for (const part of item.content ?? []) {
384
+ if (part.type !== "output_text") continue;
385
+ messageTextParts.push(part.text ?? "");
386
+ for (const annotation of part.annotations ?? []) {
387
+ if (annotation.type !== "url_citation" || !annotation.url) continue;
388
+ citations.set(annotation.url, {
389
+ title: annotation.title,
390
+ url: annotation.url,
391
+ startIndex: annotation.start_index,
392
+ endIndex: annotation.end_index,
393
+ });
394
+ }
395
+ }
396
+ }