pi-codex-search 0.1.1 → 0.1.3
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 +198 -50
- package/index.ts +922 -93
- package/package.json +10 -4
- package/scripts/codex-e2e.ts +797 -0
- package/src/codex.ts +90 -352
- package/src/command.ts +564 -0
- package/src/config.ts +287 -0
- package/src/cookies.ts +131 -0
- package/src/errors.ts +56 -0
- package/src/modes/responses.ts +310 -0
- package/src/modes/standalone.ts +378 -0
- package/src/modes/types.ts +41 -0
- package/src/ref-store.ts +74 -0
- package/src/transport.ts +110 -0
- package/src/ua.ts +67 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { SearchContextSize } from "./codex.ts";
|
|
5
|
+
|
|
6
|
+
export type SearchApi = "standalone" | "responses";
|
|
7
|
+
|
|
8
|
+
export type Freshness = "live" | "cached" | "indexed";
|
|
9
|
+
|
|
10
|
+
export type ConfigScope = "project" | "home";
|
|
11
|
+
|
|
12
|
+
export interface PiCodexSearchConfig {
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
toolName?: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
baseUrl?: string;
|
|
17
|
+
clientVersion?: string;
|
|
18
|
+
searchContextSize?: SearchContextSize;
|
|
19
|
+
freshness?: Freshness;
|
|
20
|
+
searchApi?: SearchApi;
|
|
21
|
+
standaloneEnabled?: boolean;
|
|
22
|
+
batchSize?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ResolvedConfig {
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
toolName: string;
|
|
28
|
+
model?: string;
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
clientVersion?: string;
|
|
31
|
+
defaultSearchContextSize: SearchContextSize;
|
|
32
|
+
defaultFreshness: Freshness;
|
|
33
|
+
searchApi: SearchApi;
|
|
34
|
+
standaloneEnabled: boolean;
|
|
35
|
+
batchSize: number;
|
|
36
|
+
sources: {
|
|
37
|
+
project?: PiCodexSearchConfig;
|
|
38
|
+
home?: PiCodexSearchConfig;
|
|
39
|
+
env?: PiCodexSearchConfig;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_ENABLED = true;
|
|
44
|
+
export const DEFAULT_TOOL_NAME = "codex_search";
|
|
45
|
+
export const DEFAULT_SEARCH_CONTEXT_SIZE: SearchContextSize = "medium";
|
|
46
|
+
export const DEFAULT_FRESHNESS: Freshness = "live";
|
|
47
|
+
export const DEFAULT_SEARCH_API: SearchApi = "responses";
|
|
48
|
+
export const DEFAULT_STANDALONE_ENABLED = false;
|
|
49
|
+
export const STANDALONE_TOOL_NAME = "codex_standalone_web";
|
|
50
|
+
export const DEFAULT_BATCH_SIZE = 5;
|
|
51
|
+
export const CONFIG_FILE_NAME = "pi-codex-search.json";
|
|
52
|
+
const TOOL_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/;
|
|
53
|
+
const CONTEXT_SIZES: readonly SearchContextSize[] = ["low", "medium", "high"] as const;
|
|
54
|
+
const FRESHNESS_VALUES: readonly Freshness[] = ["live", "cached", "indexed"] as const;
|
|
55
|
+
const SEARCH_API_VALUES: readonly SearchApi[] = ["standalone", "responses"] as const;
|
|
56
|
+
export const MIN_BATCH_SIZE = 1;
|
|
57
|
+
export const MAX_BATCH_SIZE = 32;
|
|
58
|
+
|
|
59
|
+
export function getConfigPath(scope: ConfigScope, cwd: string): string {
|
|
60
|
+
if (scope === "project") return join(cwd, ".pi", CONFIG_FILE_NAME);
|
|
61
|
+
return join(homedir(), ".pi", CONFIG_FILE_NAME);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isProjectTrustedContext(ctx: unknown): boolean {
|
|
65
|
+
if (ctx === null || ctx === undefined || typeof ctx !== "object") return true;
|
|
66
|
+
const maybe = ctx as { isProjectTrusted?: unknown };
|
|
67
|
+
if (typeof maybe.isProjectTrusted === "boolean") return maybe.isProjectTrusted;
|
|
68
|
+
if (typeof maybe.isProjectTrusted !== "function") return true;
|
|
69
|
+
try {
|
|
70
|
+
return Boolean(maybe.isProjectTrusted());
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function loadConfig(cwd: string, isProjectTrusted = true): Promise<ResolvedConfig> {
|
|
77
|
+
const homeConfig = await readConfigFile(getConfigPath("home", cwd));
|
|
78
|
+
const projectConfig = isProjectTrusted
|
|
79
|
+
? await readConfigFile(getConfigPath("project", cwd))
|
|
80
|
+
: undefined;
|
|
81
|
+
const envConfig = readEnvConfig();
|
|
82
|
+
|
|
83
|
+
const merged: PiCodexSearchConfig = {
|
|
84
|
+
...homeConfig,
|
|
85
|
+
...projectConfig,
|
|
86
|
+
...envConfig,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const resolved: ResolvedConfig = {
|
|
90
|
+
enabled: merged.enabled ?? DEFAULT_ENABLED,
|
|
91
|
+
toolName: merged.toolName ?? DEFAULT_TOOL_NAME,
|
|
92
|
+
defaultSearchContextSize: merged.searchContextSize ?? DEFAULT_SEARCH_CONTEXT_SIZE,
|
|
93
|
+
defaultFreshness: merged.freshness ?? DEFAULT_FRESHNESS,
|
|
94
|
+
searchApi: DEFAULT_SEARCH_API,
|
|
95
|
+
standaloneEnabled:
|
|
96
|
+
merged.standaloneEnabled ??
|
|
97
|
+
(merged.searchApi === "standalone" ? true : DEFAULT_STANDALONE_ENABLED),
|
|
98
|
+
batchSize: merged.batchSize ?? DEFAULT_BATCH_SIZE,
|
|
99
|
+
sources: {},
|
|
100
|
+
};
|
|
101
|
+
if (merged.model !== undefined) resolved.model = merged.model;
|
|
102
|
+
if (merged.baseUrl !== undefined) resolved.baseUrl = merged.baseUrl;
|
|
103
|
+
if (merged.clientVersion !== undefined) resolved.clientVersion = merged.clientVersion;
|
|
104
|
+
if (homeConfig) resolved.sources.home = homeConfig;
|
|
105
|
+
if (projectConfig) resolved.sources.project = projectConfig;
|
|
106
|
+
if (envConfig && Object.keys(envConfig).length > 0) resolved.sources.env = envConfig;
|
|
107
|
+
|
|
108
|
+
return resolved;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function saveConfig(
|
|
112
|
+
scope: ConfigScope,
|
|
113
|
+
cwd: string,
|
|
114
|
+
config: PiCodexSearchConfig,
|
|
115
|
+
): Promise<string> {
|
|
116
|
+
validateConfig(config, `<save:${scope}>`);
|
|
117
|
+
const filePath = getConfigPath(scope, cwd);
|
|
118
|
+
const clean = stripUndefined(config);
|
|
119
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
120
|
+
await writeFile(filePath, `${JSON.stringify(clean, null, 2)}\n`, "utf-8");
|
|
121
|
+
return filePath;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function deleteConfig(scope: ConfigScope, cwd: string): Promise<boolean> {
|
|
125
|
+
const filePath = getConfigPath(scope, cwd);
|
|
126
|
+
try {
|
|
127
|
+
await unlink(filePath);
|
|
128
|
+
return true;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function readConfigFile(filePath: string): Promise<PiCodexSearchConfig | undefined> {
|
|
136
|
+
let raw: string;
|
|
137
|
+
try {
|
|
138
|
+
raw = await readFile(filePath, "utf-8");
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
141
|
+
throw new Error(`Failed to read ${filePath}: ${(error as Error).message}`);
|
|
142
|
+
}
|
|
143
|
+
let parsed: unknown;
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(raw);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
throw new Error(`Invalid JSON in ${filePath}: ${(error as Error).message}`);
|
|
148
|
+
}
|
|
149
|
+
if (!isPlainObject(parsed)) {
|
|
150
|
+
throw new Error(`Invalid config in ${filePath}: expected a JSON object`);
|
|
151
|
+
}
|
|
152
|
+
const config = parsed as PiCodexSearchConfig;
|
|
153
|
+
validateConfig(config, filePath);
|
|
154
|
+
return config;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readEnvConfig(): PiCodexSearchConfig | undefined {
|
|
158
|
+
const env: PiCodexSearchConfig = {};
|
|
159
|
+
const enabled = booleanEnv("PI_CODEX_WEB_SEARCH_ENABLED");
|
|
160
|
+
if (enabled !== undefined) env.enabled = enabled;
|
|
161
|
+
const toolName = trimmedEnv("PI_CODEX_WEB_SEARCH_TOOL_NAME");
|
|
162
|
+
if (toolName !== undefined) env.toolName = toolName;
|
|
163
|
+
const model = trimmedEnv("PI_CODEX_WEB_SEARCH_MODEL");
|
|
164
|
+
if (model !== undefined) env.model = model;
|
|
165
|
+
const baseUrl = trimmedEnv("PI_CODEX_WEB_SEARCH_BASE_URL");
|
|
166
|
+
if (baseUrl !== undefined) env.baseUrl = baseUrl;
|
|
167
|
+
const clientVersion = trimmedEnv("PI_CODEX_WEB_SEARCH_CLIENT_VERSION");
|
|
168
|
+
if (clientVersion !== undefined) env.clientVersion = clientVersion;
|
|
169
|
+
const searchContextSize = trimmedEnv("PI_CODEX_WEB_SEARCH_CONTEXT_SIZE");
|
|
170
|
+
if (searchContextSize !== undefined) {
|
|
171
|
+
env.searchContextSize = searchContextSize as SearchContextSize;
|
|
172
|
+
}
|
|
173
|
+
const freshness = trimmedEnv("PI_CODEX_WEB_SEARCH_FRESHNESS");
|
|
174
|
+
if (freshness !== undefined) env.freshness = freshness as Freshness;
|
|
175
|
+
const searchApi = trimmedEnv("PI_CODEX_WEB_SEARCH_API");
|
|
176
|
+
if (searchApi !== undefined) env.searchApi = searchApi as SearchApi;
|
|
177
|
+
const standaloneEnabled = booleanEnv("PI_CODEX_WEB_STANDALONE_ENABLED");
|
|
178
|
+
if (standaloneEnabled !== undefined) env.standaloneEnabled = standaloneEnabled;
|
|
179
|
+
const batchSize = integerEnv("PI_CODEX_WEB_SEARCH_BATCH_SIZE");
|
|
180
|
+
if (batchSize !== undefined) env.batchSize = batchSize;
|
|
181
|
+
|
|
182
|
+
if (Object.keys(env).length === 0) return undefined;
|
|
183
|
+
validateConfig(env, "<env>");
|
|
184
|
+
return env;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function validateConfig(config: PiCodexSearchConfig, sourceLabel: string): void {
|
|
188
|
+
if (config.enabled !== undefined && typeof config.enabled !== "boolean") {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Invalid enabled in ${sourceLabel}: ${JSON.stringify(config.enabled)}. Must be a boolean.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (config.toolName !== undefined && !TOOL_NAME_PATTERN.test(config.toolName)) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Invalid toolName in ${sourceLabel}: ${JSON.stringify(config.toolName)}. ` +
|
|
196
|
+
`Must match ${TOOL_NAME_PATTERN.source}.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (config.model !== undefined && !isNonEmptyString(config.model)) {
|
|
200
|
+
throw new Error(`Invalid model in ${sourceLabel}: must be a non-empty string.`);
|
|
201
|
+
}
|
|
202
|
+
if (config.baseUrl !== undefined && !isNonEmptyString(config.baseUrl)) {
|
|
203
|
+
throw new Error(`Invalid baseUrl in ${sourceLabel}: must be a non-empty string.`);
|
|
204
|
+
}
|
|
205
|
+
if (config.clientVersion !== undefined && !isNonEmptyString(config.clientVersion)) {
|
|
206
|
+
throw new Error(`Invalid clientVersion in ${sourceLabel}: must be a non-empty string.`);
|
|
207
|
+
}
|
|
208
|
+
if (config.searchContextSize !== undefined && !CONTEXT_SIZES.includes(config.searchContextSize)) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`Invalid searchContextSize in ${sourceLabel}: ${JSON.stringify(config.searchContextSize)}. ` +
|
|
211
|
+
`Expected one of ${CONTEXT_SIZES.join(", ")}.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (config.freshness !== undefined && !FRESHNESS_VALUES.includes(config.freshness)) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Invalid freshness in ${sourceLabel}: ${JSON.stringify(config.freshness)}. ` +
|
|
217
|
+
`Expected one of ${FRESHNESS_VALUES.join(", ")}.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (config.standaloneEnabled !== undefined && typeof config.standaloneEnabled !== "boolean") {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Invalid standaloneEnabled in ${sourceLabel}: ${JSON.stringify(config.standaloneEnabled)}. Must be a boolean.`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (config.searchApi !== undefined && !SEARCH_API_VALUES.includes(config.searchApi)) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`Invalid searchApi in ${sourceLabel}: ${JSON.stringify(config.searchApi)}. ` +
|
|
228
|
+
`Expected one of ${SEARCH_API_VALUES.join(", ")}.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (config.batchSize !== undefined) {
|
|
232
|
+
if (
|
|
233
|
+
!Number.isInteger(config.batchSize) ||
|
|
234
|
+
config.batchSize < MIN_BATCH_SIZE ||
|
|
235
|
+
config.batchSize > MAX_BATCH_SIZE
|
|
236
|
+
) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`Invalid batchSize in ${sourceLabel}: ${JSON.stringify(config.batchSize)}. ` +
|
|
239
|
+
`Expected an integer between ${MIN_BATCH_SIZE} and ${MAX_BATCH_SIZE}.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function trimmedEnv(name: string): string | undefined {
|
|
246
|
+
const raw = process.env[name];
|
|
247
|
+
if (raw === undefined) return undefined;
|
|
248
|
+
const trimmed = raw.trim();
|
|
249
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function booleanEnv(name: string): boolean | undefined {
|
|
253
|
+
const raw = trimmedEnv(name);
|
|
254
|
+
if (raw === undefined) return undefined;
|
|
255
|
+
const lower = raw.toLowerCase();
|
|
256
|
+
if (lower === "true") return true;
|
|
257
|
+
if (lower === "false") return false;
|
|
258
|
+
throw new Error(`Invalid ${name}: ${JSON.stringify(raw)}. Expected 'true' or 'false'.`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function integerEnv(name: string): number | undefined {
|
|
262
|
+
const raw = trimmedEnv(name);
|
|
263
|
+
if (raw === undefined) return undefined;
|
|
264
|
+
const parsed = Number(raw);
|
|
265
|
+
if (!Number.isInteger(parsed)) {
|
|
266
|
+
throw new Error(`Invalid ${name}: ${JSON.stringify(raw)}. Expected an integer.`);
|
|
267
|
+
}
|
|
268
|
+
return parsed;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
272
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
276
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function stripUndefined(config: PiCodexSearchConfig): PiCodexSearchConfig {
|
|
280
|
+
const clean: PiCodexSearchConfig = {};
|
|
281
|
+
for (const [key, value] of Object.entries(config)) {
|
|
282
|
+
if (value !== undefined) {
|
|
283
|
+
(clean as Record<string, unknown>)[key] = value;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return clean;
|
|
287
|
+
}
|
package/src/cookies.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local Cloudflare cookie store for ChatGPT endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the constraints in codex chatgpt_cloudflare_cookies.rs:
|
|
5
|
+
* - HTTPS only
|
|
6
|
+
* - ChatGPT host allowlist
|
|
7
|
+
* - Cloudflare infrastructure cookie name allowlist only
|
|
8
|
+
* - Never store account/session/auth cookies
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
12
|
+
|
|
13
|
+
interface Cookie {
|
|
14
|
+
name: string;
|
|
15
|
+
value: string;
|
|
16
|
+
domain: string;
|
|
17
|
+
path: string;
|
|
18
|
+
secure: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ALLOWED_HOSTS = ["chatgpt.com", "chat.openai.com", "chatgpt-staging.com"];
|
|
22
|
+
const HOST_SUFFIXES = [".chatgpt.com", ".chatgpt-staging.com"];
|
|
23
|
+
|
|
24
|
+
const ALLOWED_COOKIE_NAMES = new Set([
|
|
25
|
+
"__cf_bm",
|
|
26
|
+
"__cflb",
|
|
27
|
+
"__cfruid",
|
|
28
|
+
"__cfseq",
|
|
29
|
+
"__cfwaitingroom",
|
|
30
|
+
"_cfuvid",
|
|
31
|
+
"cf_clearance",
|
|
32
|
+
"cf_ob_info",
|
|
33
|
+
"cf_use_ob",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function isAllowedHost(host: string): boolean {
|
|
37
|
+
if (ALLOWED_HOSTS.includes(host)) return true;
|
|
38
|
+
return HOST_SUFFIXES.some((suffix) => host.endsWith(suffix));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isAllowedCookieName(name: string): boolean {
|
|
42
|
+
if (ALLOWED_COOKIE_NAMES.has(name)) return true;
|
|
43
|
+
return name.startsWith("cf_chl_");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseSetCookieHeader(header: string, url: URL): Cookie | undefined {
|
|
47
|
+
const [firstPart] = header.split(";");
|
|
48
|
+
if (!firstPart) return undefined;
|
|
49
|
+
const eqIndex = firstPart.indexOf("=");
|
|
50
|
+
if (eqIndex <= 0) return undefined;
|
|
51
|
+
const name = firstPart.slice(0, eqIndex).trim();
|
|
52
|
+
const value = firstPart.slice(eqIndex + 1).trim();
|
|
53
|
+
if (!isAllowedCookieName(name)) return undefined;
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
value,
|
|
57
|
+
domain: url.hostname,
|
|
58
|
+
path: "/",
|
|
59
|
+
secure: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cookieKey(cookie: Cookie): string {
|
|
64
|
+
return `${cookie.domain}:${cookie.path}:${cookie.name}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class ChatGptCloudflareCookieStore {
|
|
68
|
+
private cookies = new Map<string, Cookie>();
|
|
69
|
+
|
|
70
|
+
setCookies(setCookieHeaders: string[], url: URL): void {
|
|
71
|
+
if (url.protocol !== "https:") return;
|
|
72
|
+
if (!isAllowedHost(url.hostname)) return;
|
|
73
|
+
for (const header of setCookieHeaders) {
|
|
74
|
+
const cookie = parseSetCookieHeader(header, url);
|
|
75
|
+
if (cookie) {
|
|
76
|
+
this.cookies.set(cookieKey(cookie), cookie);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
cookiesForUrl(url: URL): string | undefined {
|
|
82
|
+
if (url.protocol !== "https:") return undefined;
|
|
83
|
+
if (!isAllowedHost(url.hostname)) return undefined;
|
|
84
|
+
const parts: string[] = [];
|
|
85
|
+
for (const cookie of this.cookies.values()) {
|
|
86
|
+
if (cookie.domain === url.hostname) {
|
|
87
|
+
parts.push(`${cookie.name}=${cookie.value}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return parts.length > 0 ? parts.join("; ") : undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const SHARED_STORE = new ChatGptCloudflareCookieStore();
|
|
95
|
+
|
|
96
|
+
export function getSharedCookieStore(): ChatGptCloudflareCookieStore {
|
|
97
|
+
return SHARED_STORE;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function wrapFetchWithCookies(fetchImpl: FetchLike): FetchLike {
|
|
101
|
+
return async (input, init) => {
|
|
102
|
+
const url = new URL(
|
|
103
|
+
typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
|
|
104
|
+
);
|
|
105
|
+
const cookieHeader = SHARED_STORE.cookiesForUrl(url);
|
|
106
|
+
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
|
107
|
+
new Headers(init?.headers).forEach((value, key) => headers.set(key, value));
|
|
108
|
+
if (cookieHeader) {
|
|
109
|
+
const existing = headers.get("cookie");
|
|
110
|
+
headers.set("cookie", existing ? `${existing}; ${cookieHeader}` : cookieHeader);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const response = await fetchImpl(input, { ...init, headers });
|
|
114
|
+
|
|
115
|
+
const setCookie =
|
|
116
|
+
response.headers.getSetCookie?.() ?? parseSetCookieLegacy(response.headers.get("set-cookie"));
|
|
117
|
+
if (setCookie.length > 0) {
|
|
118
|
+
SHARED_STORE.setCookies(setCookie, url);
|
|
119
|
+
}
|
|
120
|
+
return response;
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseSetCookieLegacy(value: string | null): string[] {
|
|
125
|
+
if (!value) return [];
|
|
126
|
+
// Split on comma, but Set-Cookie values rarely contain bare commas in CF cookies.
|
|
127
|
+
return value
|
|
128
|
+
.split(",")
|
|
129
|
+
.map((s) => s.trim())
|
|
130
|
+
.filter(Boolean);
|
|
131
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type CodexErrorKind = "auth" | "rate_limit" | "transport" | "timeout" | "schema" | "unknown";
|
|
2
|
+
|
|
3
|
+
export class CodexError extends Error {
|
|
4
|
+
readonly kind: CodexErrorKind;
|
|
5
|
+
readonly status?: number;
|
|
6
|
+
|
|
7
|
+
constructor(kind: CodexErrorKind, message: string, status?: number) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "CodexError";
|
|
10
|
+
this.kind = kind;
|
|
11
|
+
if (status !== undefined) this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function classifyError(error: unknown): CodexErrorKind {
|
|
16
|
+
if (error instanceof CodexError) return error.kind;
|
|
17
|
+
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
|
18
|
+
return "timeout";
|
|
19
|
+
}
|
|
20
|
+
return "unknown";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function classifyHttpStatus(status: number): CodexErrorKind {
|
|
24
|
+
if (status === 401 || status === 403) return "auth";
|
|
25
|
+
if (status === 429) return "rate_limit";
|
|
26
|
+
return "transport";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatHttpErrorBody(text: string, mode: "responses" | "standalone"): string {
|
|
30
|
+
if (isCloudflareChallenge(text)) {
|
|
31
|
+
const advice =
|
|
32
|
+
mode === "standalone"
|
|
33
|
+
? "Use codex_search for search, or retry after Codex/ChatGPT has refreshed its Cloudflare clearance."
|
|
34
|
+
: "Retry after Codex/ChatGPT has refreshed its Cloudflare clearance.";
|
|
35
|
+
return `Cloudflare challenge blocked the Codex request. ${advice}`;
|
|
36
|
+
}
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isCloudflareChallenge(text: string): boolean {
|
|
41
|
+
const lower = text.toLowerCase();
|
|
42
|
+
return (
|
|
43
|
+
lower.includes("/cdn-cgi/challenge-platform/") ||
|
|
44
|
+
lower.includes("cf_chl_") ||
|
|
45
|
+
lower.includes("enable javascript and cookies to continue")
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function classifyEventErrorMessage(message: string): CodexErrorKind {
|
|
50
|
+
const lower = message.toLowerCase();
|
|
51
|
+
if (/rate[- ]?limit|too many requests|quota|429/.test(lower)) return "rate_limit";
|
|
52
|
+
if (/auth|unauthori[sz]ed|forbidden|401|403/.test(lower)) return "auth";
|
|
53
|
+
if (/timeout|timed out/.test(lower)) return "timeout";
|
|
54
|
+
if (/network|connection|disconnect|transport|fetch failed/.test(lower)) return "transport";
|
|
55
|
+
return "unknown";
|
|
56
|
+
}
|