@monotykamary/pi-better-openai 0.1.25 → 0.1.26

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.
package/README.md CHANGED
@@ -34,11 +34,13 @@ Usage display, image generation, and live voice require pi's `openai-codex` OAut
34
34
  - Interactive settings picker via `/openai-settings`.
35
35
  - Footer customization for model, thinking, fast mode, usage, and token/cost context.
36
36
  - OpenAI image generation/editing through the `openai_image` tool and `/openai-image` command.
37
+ - Live web search through the `openai_websearch` tool and `/openai-websearch` command, backed by the ChatGPT Codex search backend.
37
38
  - Codex-backed realtime voice through `/live`, with an animated microphone waveform and coding-task delegation into the active pi session.
38
39
  - Animated Codex custom pets rendered in the Better OpenAI footer.
39
40
  - Commands:
40
41
  - `/fast` toggles fast mode.
41
42
  - `/openai-image <prompt>` generates an image directly.
43
+ - `/openai-websearch <query>` searches the web and inserts the cited answer into the session.
42
44
  - `/live` starts or stops realtime voice mode. `Ctrl+Shift+L` is the keyboard toggle.
43
45
  - `/pets [help|list|wake [slug]|tuck|select <slug>]` renders or manages custom pets from `${CODEX_HOME:-~/.codex}/pets`.
44
46
  - `/openai-usage` shows current OpenAI subscription usage.
@@ -151,6 +153,31 @@ Save modes:
151
153
 
152
154
  The repository ignores `.pi/`, so generated images and local config should not be committed.
153
155
 
156
+ ## Web search
157
+
158
+ Use the command for a quick search:
159
+
160
+ ```text
161
+ /openai-websearch latest tanstack query release
162
+ ```
163
+
164
+ Agents can call the `openai_websearch` tool directly. Supported parameters:
165
+
166
+ - `query` (required): the web search query.
167
+ - `responseLength`: `short`, `medium`, or `long`. Defaults to the configured value.
168
+
169
+ The tool returns a synthesized answer plus cited source URLs. It calls the
170
+ undocumented `chatgpt.com/backend-api/codex/alpha/search` endpoint with your ChatGPT
171
+ OAuth credentials (`openai-codex` login), so it can change or break without notice;
172
+ OAuth/API-key-only setups without ChatGPT login are not supported.
173
+
174
+ Settings under `websearch` in the config file or the `/openai-settings` picker:
175
+
176
+ - `enabled` (default `true`), `model` (default `gpt-5.6-luna`),
177
+ `reasoningEffort` (default `max`), `responseLength` (default `short`),
178
+ `maxOutputTokens` (default `4096`, clamped to 256-100000), and
179
+ `timeoutMs` (default `25000`, clamped to 5000-120000).
180
+
154
181
  ## Codex pets
155
182
 
156
183
  Codex pets are an OpenAI Codex app feature, so the floating overlay and pet picker are still controlled by Codex (`Settings → Appearance → Pets` or `/pet`). This extension can also render compatible custom pet spritesheets directly in pi's Better OpenAI footer.
package/index.ts CHANGED
@@ -24,10 +24,12 @@ import {
24
24
  DEFAULT_LIVE_CONFIG,
25
25
  DEFAULT_PET_CONFIG,
26
26
  DEFAULT_SUPPORTED_MODELS,
27
+ DEFAULT_WEBSEARCH_CONFIG,
27
28
  PET_STATES,
28
29
  FOOTER_SETTING_DESCRIPTORS,
29
30
  USAGE_SETTING_DESCRIPTORS,
30
31
  IMAGE_SETTING_DESCRIPTORS,
32
+ WEBSEARCH_SETTING_DESCRIPTORS,
31
33
  LIVE_SETTING_DESCRIPTORS,
32
34
  PET_SETTING_DESCRIPTORS,
33
35
  FAST_SETTING_DESCRIPTORS,
@@ -51,6 +53,7 @@ import {
51
53
  readCodexAuth,
52
54
  } from "./src/usage.ts";
53
55
  import { registerOpenAIImage, _imageTest } from "./src/image.ts";
56
+ import { registerOpenAIWebSearch, _websearchTest } from "./src/websearch.ts";
54
57
  import { registerOpenAILive } from "./src/live/index.ts";
55
58
  import {
56
59
  type CodexPetPackage,
@@ -296,6 +299,8 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
296
299
  "",
297
300
  `Image enabled: ${cfg.image.enabled}`,
298
301
  `Image default save: ${cfg.image.defaultSave}`,
302
+ `Websearch enabled: ${cfg.websearch.enabled}`,
303
+ `Websearch model: ${cfg.websearch.model} (${cfg.websearch.reasoningEffort}/${cfg.websearch.responseLength}, ${cfg.websearch.maxOutputTokens} tokens, ${cfg.websearch.timeoutMs}ms)`,
299
304
  `Live enabled: ${cfg.live.enabled}`,
300
305
  `Live voice: ${cfg.live.voice}`,
301
306
  `Pet enabled: ${cfg.pets.enabled}`,
@@ -538,6 +543,12 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
538
543
  : "disabled";
539
544
  }
540
545
 
546
+ function websearchSettingsSummary(cfg: ResolvedConfig): string {
547
+ return cfg.websearch.enabled
548
+ ? `enabled · ${cfg.websearch.model} · ${cfg.websearch.reasoningEffort}/${cfg.websearch.responseLength}`
549
+ : "disabled";
550
+ }
551
+
541
552
  function liveSettingsSummary(cfg: ResolvedConfig): string {
542
553
  return cfg.live.enabled ? `enabled · ${cfg.live.voice}` : "disabled";
543
554
  }
@@ -596,6 +607,10 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
596
607
  return settingsItemsFromDescriptors(IMAGE_SETTING_DESCRIPTORS, cfg);
597
608
  }
598
609
 
610
+ function buildWebsearchSettingsItems(cfg: ResolvedConfig): SettingsPickerItem[] {
611
+ return settingsItemsFromDescriptors(WEBSEARCH_SETTING_DESCRIPTORS, cfg);
612
+ }
613
+
599
614
  function buildLiveSettingsItems(cfg: ResolvedConfig): SettingsPickerItem[] {
600
615
  return settingsItemsFromDescriptors(LIVE_SETTING_DESCRIPTORS, cfg);
601
616
  }
@@ -690,6 +705,19 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
690
705
  () => done(imageSettingsSummary(config(ctx))),
691
706
  ),
692
707
  },
708
+ {
709
+ id: "section.websearch",
710
+ label: "Web search tool",
711
+ currentValue: websearchSettingsSummary(cfg),
712
+ description: "Configure ChatGPT Codex web search defaults.",
713
+ submenu: (_value, done) =>
714
+ settingsSubmenu(
715
+ "Web search tool settings",
716
+ () => buildWebsearchSettingsItems(config(ctx)),
717
+ ctx,
718
+ () => done(websearchSettingsSummary(config(ctx))),
719
+ ),
720
+ },
693
721
  {
694
722
  id: "section.live",
695
723
  label: "Live voice",
@@ -854,6 +882,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
854
882
  });
855
883
 
856
884
  registerOpenAIImage(pi, config);
885
+ registerOpenAIWebSearch(pi, config);
857
886
  registerOpenAILive(pi, config);
858
887
  registerOpenAIPets(pi, {
859
888
  wake: async (ctx, slug) => {
@@ -1302,6 +1331,7 @@ export const _test = {
1302
1331
  DEFAULT_IMAGE_CONFIG,
1303
1332
  DEFAULT_LIVE_CONFIG,
1304
1333
  DEFAULT_PET_CONFIG,
1334
+ DEFAULT_WEBSEARCH_CONFIG,
1305
1335
  SERVICE_TIER,
1306
1336
  configPaths,
1307
1337
  abbreviateHomePath,
@@ -1324,5 +1354,6 @@ export const _test = {
1324
1354
  readCodexAuth,
1325
1355
  textPanel,
1326
1356
  imageTest: _imageTest,
1357
+ websearchTest: _websearchTest,
1327
1358
  petsTest: _petsTest,
1328
1359
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-better-openai",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "Improve OpenAI in pi with fast mode, usage stats, realtime voice, image generation, and footer polish.",
5
5
  "keywords": [
6
6
  "fast",
package/src/config.ts CHANGED
@@ -13,6 +13,8 @@ import { piAgentDir } from "./paths.ts";
13
13
  export const FOOTER_MODES = ["replace", "status", "off"] as const;
14
14
  export const IMAGE_SAVE_MODES = ["none", "project", "global", "custom"] as const;
15
15
  export const IMAGE_OUTPUT_FORMATS = ["png", "jpeg", "webp"] as const;
16
+ export const WEBSEARCH_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
17
+ export const WEBSEARCH_RESPONSE_LENGTHS = ["short", "medium", "long"] as const;
16
18
  export const PET_PLACEMENTS = [
17
19
  "stacked",
18
20
  "inline-left",
@@ -45,6 +47,8 @@ export const DEFAULT_SUPPORTED_MODELS = [
45
47
  export type FooterMode = (typeof FOOTER_MODES)[number];
46
48
  export type ImageSaveMode = (typeof IMAGE_SAVE_MODES)[number];
47
49
  export type ImageOutputFormat = (typeof IMAGE_OUTPUT_FORMATS)[number];
50
+ export type WebsearchReasoningEffort = (typeof WEBSEARCH_REASONING_EFFORTS)[number];
51
+ export type WebsearchResponseLength = (typeof WEBSEARCH_RESPONSE_LENGTHS)[number];
48
52
  export type PetPlacement = (typeof PET_PLACEMENTS)[number];
49
53
  export type PetState = (typeof PET_STATES)[number];
50
54
 
@@ -67,6 +71,15 @@ export type ImageConfig = {
67
71
  timeoutMs?: number;
68
72
  };
69
73
 
74
+ export type WebsearchConfig = {
75
+ enabled?: boolean;
76
+ model?: string;
77
+ reasoningEffort?: WebsearchReasoningEffort;
78
+ responseLength?: WebsearchResponseLength;
79
+ maxOutputTokens?: number;
80
+ timeoutMs?: number;
81
+ };
82
+
70
83
  export type LiveConfig = {
71
84
  enabled?: boolean;
72
85
  voice?: LiveVoice;
@@ -93,6 +106,7 @@ export interface ConfigFile {
93
106
  usage?: UsageConfig;
94
107
  footer?: FooterConfig;
95
108
  image?: ImageConfig;
109
+ websearch?: WebsearchConfig;
96
110
  live?: LiveConfig;
97
111
  pets?: PetConfig;
98
112
  }
@@ -115,6 +129,7 @@ export interface ResolvedConfig {
115
129
  usage: Required<UsageConfig>;
116
130
  footer: Required<FooterConfig>;
117
131
  image: Required<ImageConfig>;
132
+ websearch: Required<WebsearchConfig>;
118
133
  live: Required<LiveConfig>;
119
134
  pets: Required<PetConfig>;
120
135
  }
@@ -140,6 +155,17 @@ export const DEFAULT_IMAGE_CONFIG: Required<ImageConfig> = {
140
155
  timeoutMs: 180_000,
141
156
  };
142
157
 
158
+ export const DEFAULT_WEBSEARCH_MODEL = "gpt-5.6-luna";
159
+
160
+ export const DEFAULT_WEBSEARCH_CONFIG: Required<WebsearchConfig> = {
161
+ enabled: true,
162
+ model: DEFAULT_WEBSEARCH_MODEL,
163
+ reasoningEffort: "max",
164
+ responseLength: "short",
165
+ maxOutputTokens: 4096,
166
+ timeoutMs: 25_000,
167
+ };
168
+
143
169
  export const DEFAULT_LIVE_CONFIG: Required<LiveConfig> = {
144
170
  enabled: true,
145
171
  voice: DEFAULT_LIVE_VOICE,
@@ -166,11 +192,19 @@ export const DEFAULT_CONFIG: ConfigFile = {
166
192
  usage: DEFAULT_USAGE_CONFIG,
167
193
  footer: DEFAULT_FOOTER_CONFIG,
168
194
  image: DEFAULT_IMAGE_CONFIG,
195
+ websearch: DEFAULT_WEBSEARCH_CONFIG,
169
196
  live: DEFAULT_LIVE_CONFIG,
170
197
  pets: DEFAULT_PET_CONFIG,
171
198
  };
172
199
 
173
- export type SettingsOptionSection = "root" | "usage" | "footer" | "image" | "live" | "pets";
200
+ export type SettingsOptionSection =
201
+ | "root"
202
+ | "usage"
203
+ | "footer"
204
+ | "image"
205
+ | "websearch"
206
+ | "live"
207
+ | "pets";
174
208
 
175
209
  export type SettingsValueContext = {
176
210
  petEmptyValue?: string;
@@ -314,6 +348,69 @@ export const IMAGE_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
314
348
  },
315
349
  ];
316
350
 
351
+ export const WEBSEARCH_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
352
+ {
353
+ id: "websearch.enabled",
354
+ section: "websearch",
355
+ key: "enabled",
356
+ label: "Web search tool",
357
+ currentValue: (cfg) => String(cfg.websearch.enabled),
358
+ values: ["true", "false"],
359
+ description: "Allow the openai_websearch tool to make search requests.",
360
+ parse: booleanSetting,
361
+ },
362
+ {
363
+ id: "websearch.model",
364
+ section: "websearch",
365
+ key: "model",
366
+ label: "Search model",
367
+ currentValue: (cfg) => cfg.websearch.model,
368
+ values: ["gpt-5.6-luna"],
369
+ description: "Model used by the ChatGPT Codex search backend.",
370
+ parse: stringSetting,
371
+ },
372
+ {
373
+ id: "websearch.reasoningEffort",
374
+ section: "websearch",
375
+ key: "reasoningEffort",
376
+ label: "Search reasoning",
377
+ currentValue: (cfg) => cfg.websearch.reasoningEffort,
378
+ values: WEBSEARCH_REASONING_EFFORTS,
379
+ description: "Reasoning effort requested from the search backend.",
380
+ parse: stringSetting,
381
+ },
382
+ {
383
+ id: "websearch.responseLength",
384
+ section: "websearch",
385
+ key: "responseLength",
386
+ label: "Answer length",
387
+ currentValue: (cfg) => cfg.websearch.responseLength,
388
+ values: WEBSEARCH_RESPONSE_LENGTHS,
389
+ description: "Default answer length produced by the search backend.",
390
+ parse: stringSetting,
391
+ },
392
+ {
393
+ id: "websearch.maxOutputTokens",
394
+ section: "websearch",
395
+ key: "maxOutputTokens",
396
+ label: "Max output tokens",
397
+ currentValue: (cfg) => String(cfg.websearch.maxOutputTokens),
398
+ values: ["1024", "2048", "4096", "8192"],
399
+ description: "Maximum output tokens for a search request.",
400
+ parse: numberSetting,
401
+ },
402
+ {
403
+ id: "websearch.timeoutMs",
404
+ section: "websearch",
405
+ key: "timeoutMs",
406
+ label: "Search timeout",
407
+ currentValue: (cfg) => String(cfg.websearch.timeoutMs),
408
+ values: ["10000", "25000", "60000", "120000"],
409
+ description: "Web search request timeout in milliseconds.",
410
+ parse: numberSetting,
411
+ },
412
+ ];
413
+
317
414
  export const LIVE_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
318
415
  {
319
416
  id: "live.enabled",
@@ -446,6 +543,7 @@ export const SETTINGS_OPTION_DESCRIPTORS: readonly SettingsOptionDescriptor[] =
446
543
  ...FOOTER_SETTING_DESCRIPTORS,
447
544
  ...USAGE_SETTING_DESCRIPTORS,
448
545
  ...IMAGE_SETTING_DESCRIPTORS,
546
+ ...WEBSEARCH_SETTING_DESCRIPTORS,
449
547
  ...LIVE_SETTING_DESCRIPTORS,
450
548
  ...PET_SETTING_DESCRIPTORS,
451
549
  ];
@@ -553,6 +651,28 @@ export function readConfig(path: string): ConfigFile | undefined {
553
651
  config.image.outputFormat = parsed.image.outputFormat as ImageOutputFormat;
554
652
  if (typeof parsed.image.timeoutMs === "number") config.image.timeoutMs = parsed.image.timeoutMs;
555
653
  }
654
+ if (isRecord(parsed.websearch)) {
655
+ config.websearch = {};
656
+ if (typeof parsed.websearch.enabled === "boolean")
657
+ config.websearch.enabled = parsed.websearch.enabled;
658
+ if (typeof parsed.websearch.model === "string" && parsed.websearch.model.trim())
659
+ config.websearch.model = parsed.websearch.model.trim();
660
+ if (
661
+ typeof parsed.websearch.reasoningEffort === "string" &&
662
+ (WEBSEARCH_REASONING_EFFORTS as readonly string[]).includes(parsed.websearch.reasoningEffort)
663
+ )
664
+ config.websearch.reasoningEffort = parsed.websearch
665
+ .reasoningEffort as WebsearchReasoningEffort;
666
+ if (
667
+ typeof parsed.websearch.responseLength === "string" &&
668
+ (WEBSEARCH_RESPONSE_LENGTHS as readonly string[]).includes(parsed.websearch.responseLength)
669
+ )
670
+ config.websearch.responseLength = parsed.websearch.responseLength as WebsearchResponseLength;
671
+ if (typeof parsed.websearch.maxOutputTokens === "number")
672
+ config.websearch.maxOutputTokens = parsed.websearch.maxOutputTokens;
673
+ if (typeof parsed.websearch.timeoutMs === "number")
674
+ config.websearch.timeoutMs = parsed.websearch.timeoutMs;
675
+ }
556
676
  if (isRecord(parsed.live)) {
557
677
  config.live = {};
558
678
  if (typeof parsed.live.enabled === "boolean") config.live.enabled = parsed.live.enabled;
@@ -701,6 +821,29 @@ export function resolveConfig(cwd: string): ResolvedConfig {
701
821
  ),
702
822
  ),
703
823
  },
824
+ websearch: {
825
+ ...DEFAULT_WEBSEARCH_CONFIG,
826
+ ...globalConfig.websearch,
827
+ ...projectConfig.websearch,
828
+ maxOutputTokens: Math.max(
829
+ 256,
830
+ Math.min(
831
+ 100_000,
832
+ projectConfig.websearch?.maxOutputTokens ??
833
+ globalConfig.websearch?.maxOutputTokens ??
834
+ DEFAULT_WEBSEARCH_CONFIG.maxOutputTokens,
835
+ ),
836
+ ),
837
+ timeoutMs: Math.max(
838
+ 5_000,
839
+ Math.min(
840
+ 120_000,
841
+ projectConfig.websearch?.timeoutMs ??
842
+ globalConfig.websearch?.timeoutMs ??
843
+ DEFAULT_WEBSEARCH_CONFIG.timeoutMs,
844
+ ),
845
+ ),
846
+ },
704
847
  live: {
705
848
  ...DEFAULT_LIVE_CONFIG,
706
849
  ...globalConfig.live,
@@ -0,0 +1,446 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Container, Text } from "@earendil-works/pi-tui";
3
+ import {
4
+ isRecord,
5
+ WEBSEARCH_RESPONSE_LENGTHS,
6
+ type ResolvedConfig,
7
+ type WebsearchResponseLength,
8
+ } from "./config.ts";
9
+ import { getCodexCredentials, type CodexCredentialsWithSource } from "./codex-auth.ts";
10
+ import { maskIdentifier, sanitizeDiagnosticError } from "./format.ts";
11
+
12
+ export const OPENAI_WEBSEARCH_TOOL = "openai_websearch";
13
+ export const OPENAI_WEBSEARCH_COMMAND = "openai-websearch";
14
+ export const CODEX_SEARCH_URL = "https://chatgpt.com/backend-api/codex/alpha/search";
15
+ export const SEARCH_ORIGINATOR = "codex_cli_rs";
16
+ export const MAX_QUERY_BYTES = 8 * 1024;
17
+ export const MAX_RESPONSE_BYTES = 256 * 1024;
18
+ export const MAX_TEXT_SOURCES = 10;
19
+
20
+ export type WebSearchErrorCode =
21
+ | "authentication_required"
22
+ | "authentication_failed"
23
+ | "invalid_query"
24
+ | "request_failed"
25
+ | "request_timeout"
26
+ | "request_aborted"
27
+ | "response_too_large"
28
+ | "invalid_response";
29
+
30
+ export class WebSearchError extends Error {
31
+ readonly code: WebSearchErrorCode;
32
+
33
+ constructor(code: WebSearchErrorCode, message: string) {
34
+ super(message);
35
+ this.name = "WebSearchError";
36
+ this.code = code;
37
+ }
38
+ }
39
+
40
+ export type WebSearchResult = {
41
+ url: string;
42
+ title?: string;
43
+ content?: string;
44
+ };
45
+
46
+ export type WebSearchAnswer = {
47
+ answer: string;
48
+ results: WebSearchResult[];
49
+ };
50
+
51
+ type ToolParams = {
52
+ query: string;
53
+ responseLength?: WebsearchResponseLength;
54
+ };
55
+
56
+ const TOOL_PARAMS = {
57
+ type: "object",
58
+ properties: {
59
+ query: {
60
+ type: "string",
61
+ description: "The web search query. Pass the user's information need verbatim.",
62
+ },
63
+ responseLength: {
64
+ type: "string",
65
+ enum: WEBSEARCH_RESPONSE_LENGTHS,
66
+ description:
67
+ "Answer length produced by the search backend. Defaults to the configured value.",
68
+ },
69
+ },
70
+ required: ["query"],
71
+ additionalProperties: false,
72
+ } as const;
73
+
74
+ export type WebSearchDebug = {
75
+ authFound: boolean;
76
+ authSource?: string;
77
+ accountId?: string;
78
+ endpoint: string;
79
+ model: string;
80
+ reasoningEffort: string;
81
+ enabled: boolean;
82
+ lastStatus?: string;
83
+ lastError?: string;
84
+ };
85
+
86
+ async function getCredentials(
87
+ ctx: ExtensionContext,
88
+ signal?: AbortSignal,
89
+ ): Promise<CodexCredentialsWithSource> {
90
+ const credentials = await getCodexCredentials(ctx, signal);
91
+ if (credentials) return credentials;
92
+ throw new WebSearchError(
93
+ "authentication_required",
94
+ "Missing openai-codex OAuth credentials. Run /login openai-codex.",
95
+ );
96
+ }
97
+
98
+ export function validateSearchQuery(query: string): string {
99
+ const value = query.trim();
100
+ if (!value) throw new WebSearchError("invalid_query", "Web search requires a non-empty query.");
101
+ if (new TextEncoder().encode(value).byteLength > MAX_QUERY_BYTES) {
102
+ throw new WebSearchError(
103
+ "invalid_query",
104
+ `Web search query exceeded ${MAX_QUERY_BYTES} UTF-8 bytes.`,
105
+ );
106
+ }
107
+ return value;
108
+ }
109
+
110
+ export function buildSearchRequestBody(
111
+ query: string,
112
+ cfg: ResolvedConfig["websearch"],
113
+ responseLength: WebsearchResponseLength,
114
+ id: string,
115
+ ): Record<string, unknown> {
116
+ return {
117
+ id,
118
+ model: cfg.model,
119
+ reasoning: { effort: cfg.reasoningEffort },
120
+ input: query,
121
+ commands: {
122
+ search_query: [{ q: query }],
123
+ response_length: responseLength,
124
+ },
125
+ settings: {
126
+ allowed_callers: ["direct"],
127
+ external_web_access: true,
128
+ },
129
+ max_output_tokens: cfg.maxOutputTokens,
130
+ };
131
+ }
132
+
133
+ export function buildSearchHeaders(
134
+ credentials: CodexCredentialsWithSource,
135
+ ): Record<string, string> {
136
+ return {
137
+ authorization: `Bearer ${credentials.accessToken}`,
138
+ "chatgpt-account-id": credentials.accountId,
139
+ accept: "application/json",
140
+ "content-type": "application/json",
141
+ originator: SEARCH_ORIGINATOR,
142
+ "User-Agent": "codex_cli_rs/0.0.0 (pi-better-openai)",
143
+ };
144
+ }
145
+
146
+ function trimmedText(value: unknown): string | undefined {
147
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
148
+ }
149
+
150
+ function isSafeUrl(value: string): boolean {
151
+ try {
152
+ const protocol = new URL(value).protocol;
153
+ return protocol === "http:" || protocol === "https:";
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ export function parseSearchResponse(body: string): WebSearchAnswer {
160
+ let value: unknown;
161
+ try {
162
+ value = JSON.parse(body);
163
+ } catch {
164
+ throw new WebSearchError("invalid_response", "ChatGPT web search returned invalid JSON.");
165
+ }
166
+ if (!isRecord(value) || typeof value.output !== "string") {
167
+ throw new WebSearchError(
168
+ "invalid_response",
169
+ "ChatGPT web search returned an invalid response.",
170
+ );
171
+ }
172
+ if (value.results === undefined || value.results === null) {
173
+ throw new WebSearchError(
174
+ "invalid_response",
175
+ "ChatGPT web search did not return structured citations; the alpha protocol may have changed.",
176
+ );
177
+ }
178
+ if (!Array.isArray(value.results)) {
179
+ throw new WebSearchError("invalid_response", "ChatGPT web search returned invalid results.");
180
+ }
181
+ const results = value.results.flatMap((result): WebSearchResult[] => {
182
+ if (!isRecord(result) || result.type !== "text_result" || typeof result.url !== "string")
183
+ return [];
184
+ if (!isSafeUrl(result.url)) return [];
185
+ const title = trimmedText(result.title);
186
+ const content = trimmedText(result.snippet);
187
+ return [{ url: result.url, ...(title ? { title } : {}), ...(content ? { content } : {}) }];
188
+ });
189
+ return { answer: value.output.trim(), results };
190
+ }
191
+
192
+ async function readBoundedResponse(response: Response): Promise<string> {
193
+ const declared = response.headers.get("content-length");
194
+ if (declared && /^\d+$/.test(declared) && Number(declared) > MAX_RESPONSE_BYTES) {
195
+ await response.body?.cancel().catch(() => undefined);
196
+ throw new WebSearchError(
197
+ "response_too_large",
198
+ `ChatGPT web search response exceeded ${MAX_RESPONSE_BYTES} bytes.`,
199
+ );
200
+ }
201
+ if (!response.body) return "";
202
+
203
+ const reader = response.body.getReader();
204
+ const chunks: Uint8Array[] = [];
205
+ let size = 0;
206
+ try {
207
+ for (;;) {
208
+ const item = await reader.read();
209
+ if (item.done) break;
210
+ size += item.value.byteLength;
211
+ if (size > MAX_RESPONSE_BYTES) {
212
+ await reader.cancel().catch(() => undefined);
213
+ throw new WebSearchError(
214
+ "response_too_large",
215
+ `ChatGPT web search response exceeded ${MAX_RESPONSE_BYTES} bytes.`,
216
+ );
217
+ }
218
+ chunks.push(item.value);
219
+ }
220
+ } finally {
221
+ reader.releaseLock();
222
+ }
223
+
224
+ const body = new Uint8Array(size);
225
+ let offset = 0;
226
+ for (const chunk of chunks) {
227
+ body.set(chunk, offset);
228
+ offset += chunk.byteLength;
229
+ }
230
+ return new TextDecoder().decode(body);
231
+ }
232
+
233
+ export function formatSearchText(query: string, answer: WebSearchAnswer): string {
234
+ const lines: string[] = [];
235
+ if (answer.answer) lines.push(answer.answer, "");
236
+ const sources = answer.results.slice(0, MAX_TEXT_SOURCES);
237
+ if (sources.length > 0) {
238
+ lines.push("Sources:");
239
+ sources.forEach((result, index) => {
240
+ lines.push(`${index + 1}. [${result.title ?? result.url}](${result.url})`);
241
+ if (result.content) lines.push(` ${result.content}`);
242
+ });
243
+ }
244
+ if (lines.length === 0) return `No web results found for "${query}".`;
245
+ return lines.join("\n").trim();
246
+ }
247
+
248
+ async function requestWebSearch(
249
+ params: ToolParams,
250
+ ctx: ExtensionContext,
251
+ cfg: ResolvedConfig,
252
+ requestSignal?: AbortSignal,
253
+ ): Promise<{ query: string; answer: WebSearchAnswer; model: string }> {
254
+ if (!cfg.websearch.enabled) throw new Error("OpenAI web search is disabled in config.");
255
+ const query = validateSearchQuery(String(params.query ?? ""));
256
+ const responseLength = params.responseLength ?? cfg.websearch.responseLength;
257
+ const timeoutSignal = AbortSignal.timeout(cfg.websearch.timeoutMs);
258
+ const baseSignal = requestSignal ?? ctx.signal;
259
+ const signal = baseSignal ? AbortSignal.any([baseSignal, timeoutSignal]) : timeoutSignal;
260
+ const credentials = await getCredentials(ctx, signal);
261
+
262
+ let response: Response;
263
+ try {
264
+ response = await fetch(CODEX_SEARCH_URL, {
265
+ method: "POST",
266
+ headers: buildSearchHeaders(credentials),
267
+ body: JSON.stringify(
268
+ buildSearchRequestBody(query, cfg.websearch, responseLength, crypto.randomUUID()),
269
+ ),
270
+ signal,
271
+ redirect: "error",
272
+ credentials: "omit",
273
+ cache: "no-store",
274
+ referrerPolicy: "no-referrer",
275
+ });
276
+ } catch (error) {
277
+ if (error instanceof WebSearchError) throw error;
278
+ if (baseSignal?.aborted)
279
+ throw new WebSearchError("request_aborted", "ChatGPT web search was aborted.");
280
+ if (timeoutSignal.aborted)
281
+ throw new WebSearchError(
282
+ "request_timeout",
283
+ `ChatGPT web search timed out after ${cfg.websearch.timeoutMs}ms.`,
284
+ );
285
+ throw new WebSearchError(
286
+ "request_failed",
287
+ `ChatGPT web search request failed: ${sanitizeDiagnosticError(error instanceof Error ? error.message : String(error))}`,
288
+ );
289
+ }
290
+
291
+ if (response.url && new URL(response.url).origin !== new URL(CODEX_SEARCH_URL).origin) {
292
+ await response.body?.cancel().catch(() => undefined);
293
+ throw new WebSearchError(
294
+ "request_failed",
295
+ "ChatGPT web search returned from an unexpected origin.",
296
+ );
297
+ }
298
+ if (!response.ok) {
299
+ await response.body?.cancel().catch(() => undefined);
300
+ if (response.status === 401 || response.status === 403) {
301
+ throw new WebSearchError(
302
+ "authentication_failed",
303
+ `ChatGPT web search authentication failed (HTTP ${response.status}). Reconnect with /login openai-codex.`,
304
+ );
305
+ }
306
+ throw new WebSearchError(
307
+ "request_failed",
308
+ `ChatGPT web search failed (HTTP ${response.status}).`,
309
+ );
310
+ }
311
+
312
+ const parsed = parseSearchResponse(await readBoundedResponse(response));
313
+ return { query, answer: parsed, model: cfg.websearch.model };
314
+ }
315
+
316
+ export function registerOpenAIWebSearch(
317
+ pi: ExtensionAPI,
318
+ getConfig: (ctx: ExtensionContext) => ResolvedConfig,
319
+ ): { getDebug: (ctx: ExtensionContext) => Promise<WebSearchDebug> } {
320
+ let lastStatus: string | undefined;
321
+ let lastError: string | undefined;
322
+
323
+ async function search(
324
+ params: ToolParams,
325
+ ctx: ExtensionContext,
326
+ requestSignal?: AbortSignal,
327
+ ): Promise<{ text: string; details: Record<string, unknown> }> {
328
+ try {
329
+ lastStatus = "requesting";
330
+ lastError = undefined;
331
+ const result = await requestWebSearch(params, ctx, getConfig(ctx), requestSignal);
332
+ lastStatus = `completed (${result.answer.results.length} sources)`;
333
+ return {
334
+ text: formatSearchText(result.query, result.answer),
335
+ details: {
336
+ query: result.query,
337
+ answer: result.answer.answer,
338
+ results: result.answer.results,
339
+ model: result.model,
340
+ },
341
+ };
342
+ } catch (error) {
343
+ lastStatus = "error";
344
+ lastError = sanitizeDiagnosticError(error instanceof Error ? error.message : String(error));
345
+ throw error;
346
+ }
347
+ }
348
+
349
+ async function getDebug(ctx: ExtensionContext): Promise<WebSearchDebug> {
350
+ const cfg = getConfig(ctx);
351
+ let credentials: CodexCredentialsWithSource | undefined;
352
+ try {
353
+ credentials = await getCredentials(ctx);
354
+ } catch {
355
+ credentials = undefined;
356
+ }
357
+ return {
358
+ authFound: credentials !== undefined,
359
+ authSource: credentials?.source,
360
+ accountId: maskIdentifier(credentials?.accountId),
361
+ endpoint: CODEX_SEARCH_URL,
362
+ model: cfg.websearch.model,
363
+ reasoningEffort: cfg.websearch.reasoningEffort,
364
+ enabled: cfg.websearch.enabled,
365
+ lastStatus,
366
+ lastError,
367
+ };
368
+ }
369
+
370
+ pi.registerMessageRenderer("openai-websearch", (message, _options, theme) => {
371
+ const text = Array.isArray(message.content)
372
+ ? message.content
373
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
374
+ .map((part) => part.text)
375
+ .join("\n")
376
+ : String(message.content ?? "");
377
+ const container = new Container();
378
+ const box = new Box(1, 1, (line) => theme.bg("customMessageBg", line));
379
+ box.addChild(
380
+ new Text(`${theme.fg("accent", theme.bold("[openai-websearch]"))}\n\n${text}`, 0, 0),
381
+ );
382
+ container.addChild(box);
383
+ return container;
384
+ });
385
+
386
+ pi.registerCommand(OPENAI_WEBSEARCH_COMMAND, {
387
+ description: "Search the web with the ChatGPT Codex search backend",
388
+ handler: async (args, ctx) => {
389
+ const query = args.trim();
390
+ if (!query) {
391
+ ctx.ui.notify("Usage: /openai-websearch <query>", "error");
392
+ return;
393
+ }
394
+ ctx.ui.notify("Searching the web via ChatGPT...", "info");
395
+ const result = await search({ query }, ctx);
396
+ pi.sendMessage({
397
+ customType: "openai-websearch",
398
+ content: [{ type: "text", text: result.text }],
399
+ display: true,
400
+ details: result.details,
401
+ });
402
+ },
403
+ });
404
+
405
+ pi.registerTool({
406
+ name: OPENAI_WEBSEARCH_TOOL,
407
+ label: "OpenAI web search",
408
+ description:
409
+ "Search the live web through the ChatGPT Codex search backend using OpenAI subscription auth. Returns an answer with cited sources.",
410
+ promptSnippet: "Search the live web via the ChatGPT Codex search backend.",
411
+ promptGuidelines: [
412
+ "Use openai_websearch when the user asks about current events, recent releases, prices, or anything requiring live web data.",
413
+ "Prefer openai_websearch over recalling facts that may have changed recently; cite the returned source URLs in your answer.",
414
+ ],
415
+ parameters: TOOL_PARAMS,
416
+ async execute(_toolCallId, params: ToolParams, signal, onUpdate, ctx) {
417
+ onUpdate?.({
418
+ content: [{ type: "text", text: `Searching the web via ChatGPT for "${params.query}"...` }],
419
+ details: undefined,
420
+ });
421
+ const result = await search(params, ctx, signal);
422
+ return {
423
+ content: [{ type: "text", text: result.text }],
424
+ details: result.details,
425
+ };
426
+ },
427
+ });
428
+
429
+ return { getDebug };
430
+ }
431
+
432
+ export const _websearchTest = {
433
+ CODEX_SEARCH_URL,
434
+ MAX_QUERY_BYTES,
435
+ MAX_RESPONSE_BYTES,
436
+ MAX_TEXT_SOURCES,
437
+ OPENAI_WEBSEARCH_COMMAND,
438
+ OPENAI_WEBSEARCH_TOOL,
439
+ SEARCH_ORIGINATOR,
440
+ buildSearchHeaders,
441
+ buildSearchRequestBody,
442
+ formatSearchText,
443
+ parseSearchResponse,
444
+ readBoundedResponse,
445
+ validateSearchQuery,
446
+ };