@oai404iao/pi-codex-core 0.1.0-alpha.1

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 (53) hide show
  1. package/LICENSE +28 -0
  2. package/LICENSES/Apache-2.0.txt +201 -0
  3. package/LICENSES/OpenAI-Codex-NOTICE.txt +6 -0
  4. package/README.md +26 -0
  5. package/THIRD_PARTY_NOTICES.md +18 -0
  6. package/package.json +84 -0
  7. package/provenance/openai-codex-eb9dceba-reserved-tools.json +140 -0
  8. package/src/adapter/compaction/checkpoint.ts +159 -0
  9. package/src/adapter/compaction/collect.ts +51 -0
  10. package/src/adapter/compaction/http.ts +101 -0
  11. package/src/adapter/compaction/request.ts +159 -0
  12. package/src/adapter/compaction/transport.ts +125 -0
  13. package/src/adapter/compaction/websocket.ts +119 -0
  14. package/src/extension/prewarm-snapshot.ts +27 -0
  15. package/src/extension/provider-runtime.ts +101 -0
  16. package/src/extension/startup-prewarm.ts +264 -0
  17. package/src/fast-mode.ts +124 -0
  18. package/src/index.ts +257 -0
  19. package/src/native-compaction.ts +392 -0
  20. package/src/patch/apply.ts +338 -0
  21. package/src/patch/parser.ts +224 -0
  22. package/src/patch/render.ts +201 -0
  23. package/src/provider-native-tools.ts +75 -0
  24. package/src/providers/codex-apply-patch-tool.ts +23 -0
  25. package/src/providers/codex-apply-patch.lark +19 -0
  26. package/src/providers/openai-codex/cache-key.ts +52 -0
  27. package/src/providers/openai-codex/captured-stream.ts +50 -0
  28. package/src/providers/openai-codex/constants.ts +61 -0
  29. package/src/providers/openai-codex/continuation.ts +110 -0
  30. package/src/providers/openai-codex/errors.ts +130 -0
  31. package/src/providers/openai-codex/events.ts +123 -0
  32. package/src/providers/openai-codex/headers.ts +224 -0
  33. package/src/providers/openai-codex/lite.ts +24 -0
  34. package/src/providers/openai-codex/message.ts +33 -0
  35. package/src/providers/openai-codex/prewarm.ts +76 -0
  36. package/src/providers/openai-codex/proxy.ts +55 -0
  37. package/src/providers/openai-codex/reasoning.ts +54 -0
  38. package/src/providers/openai-codex/request-body.ts +149 -0
  39. package/src/providers/openai-codex/request-context.ts +20 -0
  40. package/src/providers/openai-codex/request-metadata.ts +137 -0
  41. package/src/providers/openai-codex/retry.ts +154 -0
  42. package/src/providers/openai-codex/runtime.ts +1 -0
  43. package/src/providers/openai-codex/sse.ts +93 -0
  44. package/src/providers/openai-codex/stream.ts +367 -0
  45. package/src/providers/openai-codex/urls.ts +24 -0
  46. package/src/providers/openai-codex/usage.ts +60 -0
  47. package/src/providers/openai-codex/websocket-connection.ts +216 -0
  48. package/src/providers/openai-codex/websocket-events.ts +210 -0
  49. package/src/providers/openai-codex/websocket-session.ts +192 -0
  50. package/src/providers/openai-codex/websocket-socket.ts +18 -0
  51. package/src/providers/openai-codex/websocket-stream.ts +151 -0
  52. package/src/tools/apply-patch.ts +84 -0
  53. package/src/tools/view-image.ts +98 -0
@@ -0,0 +1,54 @@
1
+ import { type Api, type Model, type ThinkingLevel } from "@earendil-works/pi-ai/compat";
2
+
3
+ export function clampReasoningEffort(modelId: string, effort: string): string {
4
+ const id = modelId.includes("/") ? (modelId.split("/").pop() ?? modelId) : modelId;
5
+ const gpt5MinorMatch = /^gpt-5\.(\d+)/.exec(id);
6
+ const gpt5Minor = gpt5MinorMatch ? Number.parseInt(gpt5MinorMatch[1], 10) : undefined;
7
+ if (gpt5Minor !== undefined && gpt5Minor >= 2 && effort === "minimal") return "low";
8
+ if (id === "gpt-5.1" && effort === "xhigh") return "high";
9
+ if (id === "gpt-5.1-codex-mini") return effort === "high" || effort === "xhigh" ? "high" : "medium";
10
+ return effort;
11
+ }
12
+
13
+ const CODEX_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
14
+
15
+ type CodexThinkingLevel = (typeof CODEX_THINKING_LEVELS)[number];
16
+
17
+ /**
18
+ * Keep this local instead of delegating to older Pi releases: Pi added `max`
19
+ * after this extension's original minimum version, and an old clamp silently
20
+ * turns it into `off`.
21
+ */
22
+ export function clampCodexThinkingLevel(model: Model<Api>, level: ThinkingLevel): CodexThinkingLevel {
23
+ if (!model.reasoning) return "off";
24
+ const available = CODEX_THINKING_LEVELS.filter((candidate) => {
25
+ if (candidate === "off") return true;
26
+ const mapped = (model.thinkingLevelMap as Record<string, string | null | undefined> | undefined)?.[candidate];
27
+ if (mapped === null) return false;
28
+ if (candidate === "xhigh" || candidate === "max") return mapped !== undefined;
29
+ return true;
30
+ });
31
+ if (available.includes(level as CodexThinkingLevel)) return level as CodexThinkingLevel;
32
+ const requestedIndex = CODEX_THINKING_LEVELS.indexOf(level as CodexThinkingLevel);
33
+ if (requestedIndex < 0) return available[0] ?? "off";
34
+ for (let index = requestedIndex; index < CODEX_THINKING_LEVELS.length; index++) {
35
+ const candidate = CODEX_THINKING_LEVELS[index]!;
36
+ if (available.includes(candidate)) return candidate;
37
+ }
38
+ for (let index = requestedIndex - 1; index >= 0; index--) {
39
+ const candidate = CODEX_THINKING_LEVELS[index]!;
40
+ if (available.includes(candidate)) return candidate;
41
+ }
42
+ return available[0] ?? "off";
43
+ }
44
+
45
+ export function thinkingLevelFromUnknown(value: unknown): ThinkingLevel | undefined {
46
+ return value === "minimal"
47
+ || value === "low"
48
+ || value === "medium"
49
+ || value === "high"
50
+ || value === "xhigh"
51
+ || value === "max"
52
+ ? value
53
+ : undefined;
54
+ }
@@ -0,0 +1,149 @@
1
+ import { type Api, type Context, type Model, type SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
2
+ import { type CodexRequestProfile } from "@oai404iao/pi-codex-runtime/internal/codex-request-profile";
3
+ import { createCodexReservedNamespaceTool } from "@oai404iao/pi-codex-runtime/internal/codex-reserved-tools";
4
+ import { resolveCodexRequestIdentity } from "@oai404iao/pi-codex-runtime/internal/codex-wire-identity";
5
+ import { type ReasoningSummary } from "@oai404iao/pi-codex-runtime/internal/model-catalog/types";
6
+ import { createCodexApplyPatchCustomTool } from "../codex-apply-patch-tool.js";
7
+ import { convertResponsesMessages } from "@oai404iao/pi-codex-runtime/internal/providers/responses/messages";
8
+ import { convertResponsesTools } from "@oai404iao/pi-codex-runtime/internal/providers/responses/tools";
9
+ import { CODEX_TOOL_CALL_PROVIDERS, WEB_SEARCH_RESULTS_INCLUDE, WEB_SEARCH_SOURCES_INCLUDE } from "./constants.js";
10
+ import { stripResponsesLiteImageDetails } from "./lite.js";
11
+ import { clampCodexThinkingLevel, clampReasoningEffort } from "./reasoning.js";
12
+ import { type ResponsesBody, type NativeToolOwnership } from "@oai404iao/pi-codex-runtime/internal/providers/openai-codex/types";
13
+
14
+ function hasNativeWebSearchTool(body: ResponsesBody): boolean {
15
+ return Array.isArray(body.tools) && body.tools.some((tool) => Boolean(tool) && typeof tool === "object" && (tool as { type?: unknown }).type === "web_search");
16
+ }
17
+
18
+ export function ensureWebSearchDetailsIncluded(body: ResponsesBody): void {
19
+ if (!hasNativeWebSearchTool(body)) return;
20
+ const include = Array.isArray(body.include) ? body.include : [];
21
+ const missing = [WEB_SEARCH_SOURCES_INCLUDE, WEB_SEARCH_RESULTS_INCLUDE].filter((value) => !include.includes(value));
22
+ if (missing.length > 0) body.include = [...include, ...missing];
23
+ }
24
+
25
+ export function buildRequestBody<TApi extends Api>(model: Model<TApi>, context: Context, profile: CodexRequestProfile, options?: SimpleStreamOptions & { ownsNativeTool?: NativeToolOwnership }): ResponsesBody {
26
+ const requestIdentity = resolveCodexRequestIdentity(
27
+ options?.sessionId,
28
+ options?.metadata as Record<string, unknown> | undefined,
29
+ // Only the session-scoped prompt cache key is needed here. Do not
30
+ // synthesize a logical turn while constructing startup/prewarm bodies.
31
+ "prewarm",
32
+ );
33
+ const messages = convertResponsesMessages(model, context, new Set([...CODEX_TOOL_CALL_PROVIDERS, model.provider]), {
34
+ includeSystemPrompt: false,
35
+ });
36
+ const tools = context.tools && context.tools.length > 0
37
+ ? convertResponsesTools(context.tools, { strict: null }).map((tool) =>
38
+ profile.patchTransport === "custom" && tool.type === "function" && tool.name === "apply_patch" ? createCodexApplyPatchCustomTool() : tool)
39
+ : [];
40
+ const lite = profile.responsesMode === "lite";
41
+ const liteTools = (): unknown[] => {
42
+ const namespaces = new Map<string, {
43
+ type: "namespace";
44
+ name: string;
45
+ description: string;
46
+ tools: unknown[];
47
+ }>();
48
+ for (const tool of tools as Array<Record<string, unknown>>) {
49
+ if (typeof tool.name !== "string") continue;
50
+ if ((tool.name === "web_search" || tool.name === "image_generation")
51
+ && options?.ownsNativeTool?.(tool.name) !== false) {
52
+ const reserved = createCodexReservedNamespaceTool(tool.name);
53
+ namespaces.set(reserved.name, reserved);
54
+ continue;
55
+ }
56
+ let namespace = namespaces.get("functions");
57
+ if (!namespace) {
58
+ namespace = {
59
+ type: "namespace",
60
+ name: "functions",
61
+ description: "",
62
+ tools: [],
63
+ };
64
+ namespaces.set("functions", namespace);
65
+ }
66
+ const nestedTool: Record<string, unknown> = { ...tool };
67
+ if (nestedTool.type === "function" && typeof nestedTool.strict !== "boolean") {
68
+ nestedTool.strict = false;
69
+ }
70
+ namespace.tools.push(nestedTool);
71
+ }
72
+ return [...namespaces.values()].filter((namespace) => namespace.tools.length > 0);
73
+ };
74
+
75
+ const body: ResponsesBody = {
76
+ model: model.id,
77
+ store: false,
78
+ stream: true,
79
+ input: [],
80
+ text: { verbosity: ((options as { textVerbosity?: string } | undefined)?.textVerbosity ?? "low") as string },
81
+ include: ["reasoning.encrypted_content"],
82
+ prompt_cache_key: requestIdentity?.sessionId ?? options?.sessionId,
83
+ tool_choice: "auto",
84
+ parallel_tool_calls: profile.supportsParallelTools,
85
+ };
86
+ if (lite) {
87
+ stripResponsesLiteImageDetails(messages);
88
+ body.input = [
89
+ { type: "additional_tools", role: "developer", tools: liteTools() },
90
+ ...(context.systemPrompt
91
+ ? [{ type: "message", role: "developer", content: [{ type: "input_text", text: context.systemPrompt }] }]
92
+ : []),
93
+ ...messages,
94
+ ];
95
+ body.reasoning = { context: "all_turns" };
96
+ } else {
97
+ if (profile.systemPromptPlacement === "instructions") {
98
+ body.instructions = context.systemPrompt;
99
+ body.input = messages;
100
+ } else {
101
+ body.input = [
102
+ ...(context.systemPrompt
103
+ ? [{ type: "message", role: "developer", content: [{ type: "input_text", text: context.systemPrompt }] }]
104
+ : []),
105
+ ...messages,
106
+ ];
107
+ }
108
+ if (tools.length > 0) body.tools = tools;
109
+ }
110
+
111
+ // The Codex ChatGPT-backed endpoint rejects output-token cap fields with
112
+ // `Unsupported parameter: max_output_tokens`. Pi's branch summarizer passes
113
+ // `maxTokens`, so forwarding it breaks `/tree` summaries and extensions that
114
+ // use `ctx.navigateTree(..., { summarize: true })`.
115
+
116
+ if ((options as { temperature?: number } | undefined)?.temperature !== undefined) {
117
+ body.temperature = (options as { temperature?: number }).temperature;
118
+ }
119
+
120
+ const serviceTier = (options as { serviceTier?: string } | undefined)?.serviceTier;
121
+ if (serviceTier !== undefined) {
122
+ body.service_tier = serviceTier;
123
+ }
124
+
125
+ const clampedReasoning = options?.reasoning
126
+ ? clampCodexThinkingLevel(model as Model<Api>, options.reasoning)
127
+ : undefined;
128
+ const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
129
+ if (reasoningEffort !== undefined) {
130
+ const effort = model.thinkingLevelMap?.[reasoningEffort] ?? reasoningEffort;
131
+ if (effort === null) return body;
132
+ const reasoning = body.reasoning ?? {};
133
+ reasoning.effort = clampReasoningEffort(model.id, effort);
134
+ const summary = (options as { reasoningSummary?: ReasoningSummary | null } | undefined)?.reasoningSummary
135
+ ?? profile.reasoningSummary;
136
+ if (summary && summary !== "none") reasoning.summary = summary;
137
+ body.reasoning = reasoning;
138
+ } else if (lite && clampedReasoning !== "off") {
139
+ // Match the Codex CLI default reasoning level for Lite models: the CLI
140
+ // always sends `reasoning.effort` (default "low") for gpt-5.6-* models.
141
+ const reasoning = body.reasoning ?? {};
142
+ if (reasoning.effort === undefined) {
143
+ reasoning.effort = model.thinkingLevelMap?.low ?? "low";
144
+ }
145
+ body.reasoning = reasoning;
146
+ }
147
+
148
+ return body;
149
+ }
@@ -0,0 +1,20 @@
1
+ import { type Context } from "@earendil-works/pi-ai/compat";
2
+
3
+ export function getLatestUserText(context: Context): string | undefined {
4
+ for (let i = context.messages.length - 1; i >= 0; i--) {
5
+ const message = context.messages[i];
6
+ if (message.role !== "user") continue;
7
+ if (typeof message.content === "string") {
8
+ const trimmed = message.content.trim();
9
+ if (trimmed) return trimmed;
10
+ continue;
11
+ }
12
+ const text = message.content
13
+ .filter((item) => item.type === "text")
14
+ .map((item) => item.text)
15
+ .join("\n")
16
+ .trim();
17
+ if (text) return text;
18
+ }
19
+ return undefined;
20
+ }
@@ -0,0 +1,137 @@
1
+ import { isUuidV7, resolveCodexRequestIdentity, uuidV7, type CodexRequestIdentity } from "@oai404iao/pi-codex-runtime/internal/codex-wire-identity";
2
+ import { WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY } from "./constants.js";
3
+ import { type ResponsesBody, type WebSocketRequestMetadata } from "@oai404iao/pi-codex-runtime/internal/providers/openai-codex/types";
4
+
5
+ export function createCodexRequestId(): string {
6
+ if (typeof globalThis.crypto?.randomUUID === "function") {
7
+ return globalThis.crypto.randomUUID();
8
+ }
9
+ return `codex_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
10
+ }
11
+
12
+ export function createPiTurnId(): string {
13
+ return uuidV7();
14
+ }
15
+
16
+ /**
17
+ * Build the `x-codex-turn-metadata` compatibility blob the CLI sends inside
18
+ * `client_metadata` for every request kind. The gateway currently rewrites
19
+ * this blob (privacy normalization); once it honors client-supplied values,
20
+ * this keeps the shape identical to the CLI.
21
+ */
22
+ export function buildCodexTurnMetadataJson(identity: CodexRequestIdentity): string {
23
+ const payload: Record<string, string | number> = {
24
+ installation_id: identity.installationId,
25
+ session_id: identity.sessionId,
26
+ thread_id: identity.threadId,
27
+ turn_id: identity.turnId,
28
+ window_id: identity.windowId,
29
+ request_kind: identity.requestKind,
30
+ ...(identity.turnStartedAtMs !== undefined
31
+ ? { turn_started_at_unix_ms: identity.turnStartedAtMs }
32
+ : {}),
33
+ ...(identity.agentName ? { agent_name: identity.agentName } : {}),
34
+ ...(identity.forkedFromThreadId
35
+ ? { forked_from_thread_id: identity.forkedFromThreadId }
36
+ : {}),
37
+ ...(identity.parentThreadId
38
+ ? { parent_thread_id: identity.parentThreadId }
39
+ : {}),
40
+ ...(identity.parentTurnId
41
+ ? { parent_turn_id: identity.parentTurnId }
42
+ : {}),
43
+ ...(identity.rootTurnId
44
+ ? { root_turn_id: identity.rootTurnId }
45
+ : {}),
46
+ ...(identity.subagentKind
47
+ ? { subagent_kind: identity.subagentKind }
48
+ : {}),
49
+ };
50
+ return JSON.stringify(payload);
51
+ }
52
+
53
+ function identityForRequestMetadata(
54
+ metadata: WebSocketRequestMetadata,
55
+ ): CodexRequestIdentity | undefined {
56
+ if (metadata.identity) return metadata.identity;
57
+ const explicit: Record<string, unknown> = {
58
+ turn_id: metadata.turnId,
59
+ };
60
+ if (isUuidV7(metadata.threadId)) explicit.thread_id = metadata.threadId;
61
+ return resolveCodexRequestIdentity(
62
+ metadata.sessionId,
63
+ explicit,
64
+ metadata.requestKind ?? "turn",
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Inject the Codex-compatible `client_metadata` for an SSE request, sourced
70
+ * from the same (session, thread, turn) metadata as the WebSocket path. The
71
+ * WebSocket-only fields (`x-codex-ws-stream-request-start-ms`, turn-state,
72
+ * Lite flag) stay out: SSE carries the Lite header and turn-state header.
73
+ */
74
+ export function withSseRequestMetadata(body: ResponsesBody, metadata: WebSocketRequestMetadata): ResponsesBody {
75
+ const identity = identityForRequestMetadata(metadata);
76
+ if (!identity) return body;
77
+ const turnMetadata = buildCodexTurnMetadataJson(identity);
78
+ return {
79
+ ...body,
80
+ client_metadata: {
81
+ ...body.client_metadata,
82
+ session_id: identity.sessionId,
83
+ thread_id: identity.threadId,
84
+ "x-codex-window-id": identity.windowId,
85
+ turn_id: identity.turnId,
86
+ "x-codex-installation-id": identity.installationId,
87
+ ...(identity.parentThreadId
88
+ ? { "x-codex-parent-thread-id": identity.parentThreadId }
89
+ : {}),
90
+ ...(identity.parentTurnId
91
+ ? { parent_turn_id: identity.parentTurnId }
92
+ : {}),
93
+ ...(identity.rootTurnId
94
+ ? { root_turn_id: identity.rootTurnId }
95
+ : {}),
96
+ ...(identity.subagentKind
97
+ ? { "x-openai-subagent": identity.subagentKind }
98
+ : {}),
99
+ ...(turnMetadata ? { "x-codex-turn-metadata": turnMetadata } : {}),
100
+ },
101
+ };
102
+ }
103
+
104
+ export function withWebSocketRequestMetadata(body: ResponsesBody, metadata: WebSocketRequestMetadata): ResponsesBody {
105
+ const identity = identityForRequestMetadata(metadata);
106
+ const turnMetadata = identity ? buildCodexTurnMetadataJson(identity) : "";
107
+ return {
108
+ ...body,
109
+ client_metadata: {
110
+ ...body.client_metadata,
111
+ ...(identity ? { session_id: identity.sessionId } : {}),
112
+ ...(identity ? { thread_id: identity.threadId } : {}),
113
+ ...(identity ? { "x-codex-window-id": identity.windowId } : {}),
114
+ turn_id: identity?.turnId ?? metadata.turnId,
115
+ ...(identity
116
+ ? { "x-codex-installation-id": identity.installationId }
117
+ : {}),
118
+ ...(identity?.parentThreadId
119
+ ? { "x-codex-parent-thread-id": identity.parentThreadId }
120
+ : {}),
121
+ ...(identity?.parentTurnId
122
+ ? { parent_turn_id: identity.parentTurnId }
123
+ : {}),
124
+ ...(identity?.rootTurnId
125
+ ? { root_turn_id: identity.rootTurnId }
126
+ : {}),
127
+ ...(identity?.subagentKind
128
+ ? { "x-openai-subagent": identity.subagentKind }
129
+ : {}),
130
+ ...(turnMetadata ? { "x-codex-turn-metadata": turnMetadata } : {}),
131
+ ...(identity?.turnState
132
+ ? { "x-codex-turn-state": identity.turnState }
133
+ : {}),
134
+ [WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY]: Date.now().toString(),
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,154 @@
1
+ import { type SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
2
+ import { DEFAULT_WEBSOCKET_STREAM_MAX_RETRIES, MAX_WEBSOCKET_STREAM_MAX_RETRIES, PREVIOUS_RESPONSE_NOT_FOUND_CODE, WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE, WEBSOCKET_RETRY_BASE_DELAY_MS, WEBSOCKET_RETRY_MAX_DELAY_MS } from "./constants.js";
3
+ import { NonRetryableProviderError, ProviderProtocolError, ProviderResponseError, WebSocketHandshakeError, isRetryableError } from "./errors.js";
4
+
5
+ export function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
6
+ return new Promise((resolve, reject) => {
7
+ if (signal?.aborted) {
8
+ reject(new Error("Request was aborted"));
9
+ return;
10
+ }
11
+
12
+ const timeout = setTimeout(resolve, ms);
13
+ signal?.addEventListener(
14
+ "abort",
15
+ () => {
16
+ clearTimeout(timeout);
17
+ reject(new Error("Request was aborted"));
18
+ },
19
+ { once: true },
20
+ );
21
+ });
22
+ }
23
+
24
+ export function isRetryableEarlyWebSocketError(error: unknown): boolean {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ return /^WebSocket (error|closed)(?:\s|$)/.test(message);
27
+ }
28
+
29
+ export function retryAfterMsFromHeaders(headers: Record<string, string> | undefined): number | undefined {
30
+ if (!headers) return undefined;
31
+ const retryAfterMs = Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after-ms")?.[1];
32
+ if (retryAfterMs) {
33
+ const parsed = Number.parseFloat(retryAfterMs);
34
+ if (Number.isFinite(parsed) && parsed >= 0) return parsed;
35
+ }
36
+ const retryAfter = Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.[1];
37
+ if (!retryAfter) return undefined;
38
+ const seconds = Number.parseFloat(retryAfter);
39
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
40
+ const date = Date.parse(retryAfter);
41
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
42
+ }
43
+
44
+ export function isRetryableWebSocketError(error: unknown): boolean {
45
+ if (error instanceof WebSocketHandshakeError) {
46
+ return isRetryableError(error.status, error.body ?? error.message);
47
+ }
48
+ if (error instanceof ProviderResponseError) {
49
+ if (
50
+ /usage_limit_reached|usage_not_included/i.test(`${error.code ?? ""} ${error.errorType ?? ""}`)
51
+ ) {
52
+ return false;
53
+ }
54
+ if (typeof error.status === "number" && isRetryableError(error.status, error.message)) return true;
55
+ return /retry|rate.?limit|overloaded|service.?unavailable|connection.?limit/i.test(
56
+ `${error.code ?? ""} ${error.errorType ?? ""} ${error.message}`,
57
+ );
58
+ }
59
+ if (error instanceof ProviderProtocolError || error instanceof NonRetryableProviderError) return false;
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ return /websocket|network|connection|socket|timed? out|timeout|fetch failed|terminated|closed before response\.completed|stream closed before response\.completed/i.test(
62
+ message,
63
+ );
64
+ }
65
+
66
+ function explicitWebSocketRetryDelayMs(error: unknown): number | undefined {
67
+ return error instanceof WebSocketHandshakeError
68
+ ? retryAfterMsFromHeaders(error.headers)
69
+ : error instanceof ProviderResponseError
70
+ ? error.retryAfterMs
71
+ : undefined;
72
+ }
73
+
74
+ function boundedWebSocketRetryDelayMs(
75
+ delayMs: number,
76
+ options: SimpleStreamOptions | undefined,
77
+ ): number {
78
+ const configuredMax = options?.maxRetryDelayMs;
79
+ const maxDelay = typeof configuredMax === "number" && Number.isFinite(configuredMax) && configuredMax >= 0
80
+ ? configuredMax
81
+ : WEBSOCKET_RETRY_MAX_DELAY_MS;
82
+ if (maxDelay > 0 && delayMs > maxDelay) {
83
+ throw new NonRetryableProviderError(
84
+ `WebSocket retry delay ${Math.round(delayMs)}ms exceeds maxRetryDelayMs ${Math.round(maxDelay)}ms`,
85
+ );
86
+ }
87
+ return delayMs;
88
+ }
89
+
90
+ export function webSocketRetryDelayMs(
91
+ error: unknown,
92
+ retryCount: number,
93
+ options: SimpleStreamOptions | undefined,
94
+ ): number {
95
+ const explicit = explicitWebSocketRetryDelayMs(error);
96
+ const connectionFailure = !(
97
+ error instanceof WebSocketHandshakeError
98
+ || error instanceof ProviderResponseError
99
+ || error instanceof ProviderProtocolError
100
+ );
101
+ const base = explicit
102
+ ?? (connectionFailure
103
+ ? Math.min(WEBSOCKET_RETRY_MAX_DELAY_MS, 5_000 * 2 ** Math.max(0, retryCount - 1))
104
+ : WEBSOCKET_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, retryCount - 1));
105
+ const jittered = explicit === undefined && !connectionFailure
106
+ ? Math.round(base * (0.9 + Math.random() * 0.2))
107
+ : base;
108
+ return boundedWebSocketRetryDelayMs(jittered, options);
109
+ }
110
+
111
+ export function webSocketCompactionRetryDelayMs(
112
+ error: unknown,
113
+ retryCount: number,
114
+ options: SimpleStreamOptions | undefined,
115
+ ): number {
116
+ const explicit = explicitWebSocketRetryDelayMs(error);
117
+ const base = explicit ?? WEBSOCKET_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, retryCount - 1);
118
+ const jittered = explicit === undefined
119
+ ? Math.round(base * (0.9 + Math.random() * 0.2))
120
+ : base;
121
+ return boundedWebSocketRetryDelayMs(jittered, options);
122
+ }
123
+
124
+ export function webSocketStreamMaxRetries(options: SimpleStreamOptions | undefined): number {
125
+ const value = options?.maxRetries;
126
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
127
+ return DEFAULT_WEBSOCKET_STREAM_MAX_RETRIES;
128
+ }
129
+ return Math.min(MAX_WEBSOCKET_STREAM_MAX_RETRIES, Math.floor(value));
130
+ }
131
+
132
+ export function isProviderNonTransportError(error: unknown): error is ProviderResponseError | ProviderProtocolError {
133
+ return error instanceof ProviderResponseError || error instanceof ProviderProtocolError;
134
+ }
135
+
136
+ export function isWebSocketUpgradeRejectedError(error: unknown): error is WebSocketHandshakeError {
137
+ // `auto` is capability negotiation, not a generic recovery path. HTTP 426
138
+ // explicitly tells the client that the WebSocket upgrade cannot be used.
139
+ // Model/request failures and transient connection errors must stay on the
140
+ // WebSocket path so an agent-level retry does not silently change transport.
141
+ return error instanceof WebSocketHandshakeError && error.status === 426;
142
+ }
143
+
144
+ export function isWebSocketConnectionLimitReachedError(error: unknown): boolean {
145
+ const candidate = error as { code?: unknown; message?: unknown };
146
+ if (candidate?.code === WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE) return true;
147
+ return typeof candidate?.message === "string" && candidate.message.includes(WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE);
148
+ }
149
+
150
+ export function isPreviousResponseNotFoundError(error: unknown): boolean {
151
+ const candidate = error as { code?: unknown; message?: unknown };
152
+ if (candidate?.code === PREVIOUS_RESPONSE_NOT_FOUND_CODE) return true;
153
+ return typeof candidate?.message === "string" && candidate.message.includes(PREVIOUS_RESPONSE_NOT_FOUND_CODE);
154
+ }
@@ -0,0 +1 @@
1
+ export const dynamicImport = (specifier: string) => import(specifier);
@@ -0,0 +1,93 @@
1
+ import { type SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
2
+ import { SSE_RESPONSE_HEADER_TIMEOUT_MS } from "./constants.js";
3
+ import { type StreamEventShape } from "@oai404iao/pi-codex-runtime/internal/providers/openai-codex/types";
4
+
5
+ export function responseHeaderTimeoutMsFromOptions(options: SimpleStreamOptions | undefined): number {
6
+ const value = (options as { timeoutMs?: unknown } | undefined)?.timeoutMs;
7
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : SSE_RESPONSE_HEADER_TIMEOUT_MS;
8
+ }
9
+
10
+ export async function fetchWithResponseHeaderTimeout(
11
+ url: string,
12
+ init: RequestInit,
13
+ parentSignal: AbortSignal | undefined,
14
+ timeoutMs = SSE_RESPONSE_HEADER_TIMEOUT_MS,
15
+ ): Promise<Response> {
16
+ if (parentSignal?.aborted) throw new Error("Request was aborted");
17
+
18
+ const controller = new AbortController();
19
+ let timedOut = false;
20
+ let parentAborted = false;
21
+ const timeoutMessage = `Codex Responses SSE response headers timed out after ${timeoutMs}ms`;
22
+
23
+ const onParentAbort = () => {
24
+ parentAborted = true;
25
+ controller.abort(parentSignal?.reason);
26
+ };
27
+
28
+ if (parentSignal) parentSignal.addEventListener("abort", onParentAbort, { once: true });
29
+ const timeout = setTimeout(() => {
30
+ timedOut = true;
31
+ controller.abort(new Error(timeoutMessage));
32
+ }, Math.max(1, timeoutMs));
33
+
34
+ try {
35
+ return await fetch(url, { ...init, signal: controller.signal });
36
+ } catch (error) {
37
+ if (timedOut) throw new Error(timeoutMessage);
38
+ if (parentAborted || parentSignal?.aborted) throw new Error("Request was aborted");
39
+ throw error;
40
+ } finally {
41
+ clearTimeout(timeout);
42
+ if (parentSignal) parentSignal.removeEventListener("abort", onParentAbort);
43
+ }
44
+ }
45
+
46
+ export async function* parseSSE(response: Response): AsyncIterable<StreamEventShape> {
47
+ if (!response.body) return;
48
+
49
+ const reader = response.body.getReader();
50
+ const decoder = new TextDecoder();
51
+ let buffer = "";
52
+
53
+ try {
54
+ while (true) {
55
+ const { done, value } = await reader.read();
56
+ if (done) break;
57
+
58
+ buffer += decoder.decode(value, { stream: true });
59
+ buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
60
+ let idx = buffer.indexOf("\n\n");
61
+ while (idx !== -1) {
62
+ const chunk = buffer.slice(0, idx);
63
+ buffer = buffer.slice(idx + 2);
64
+ const dataLines = chunk
65
+ .split("\n")
66
+ .filter((line) => line.startsWith("data:"))
67
+ .map((line) => line.slice(5).trim());
68
+ if (dataLines.length > 0) {
69
+ const data = dataLines.join("\n").trim();
70
+ if (data && data !== "[DONE]") {
71
+ try {
72
+ yield JSON.parse(data) as StreamEventShape;
73
+ } catch {
74
+ // Ignore malformed SSE chunks and continue consuming the stream.
75
+ }
76
+ }
77
+ }
78
+ idx = buffer.indexOf("\n\n");
79
+ }
80
+ }
81
+ } finally {
82
+ try {
83
+ await reader.cancel();
84
+ } catch {
85
+ // ignore cancellation errors
86
+ }
87
+ try {
88
+ reader.releaseLock();
89
+ } catch {
90
+ // ignore lock release errors
91
+ }
92
+ }
93
+ }