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.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/index.ts +163 -0
- package/package.json +68 -0
- 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
|
+
[](https://www.npmjs.com/package/pi-codex-search)
|
|
4
|
+
[](./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
|
+
}
|