@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.
- package/LICENSE +28 -0
- package/LICENSES/Apache-2.0.txt +201 -0
- package/LICENSES/OpenAI-Codex-NOTICE.txt +6 -0
- package/README.md +26 -0
- package/THIRD_PARTY_NOTICES.md +18 -0
- package/package.json +84 -0
- package/provenance/openai-codex-eb9dceba-reserved-tools.json +140 -0
- package/src/adapter/compaction/checkpoint.ts +159 -0
- package/src/adapter/compaction/collect.ts +51 -0
- package/src/adapter/compaction/http.ts +101 -0
- package/src/adapter/compaction/request.ts +159 -0
- package/src/adapter/compaction/transport.ts +125 -0
- package/src/adapter/compaction/websocket.ts +119 -0
- package/src/extension/prewarm-snapshot.ts +27 -0
- package/src/extension/provider-runtime.ts +101 -0
- package/src/extension/startup-prewarm.ts +264 -0
- package/src/fast-mode.ts +124 -0
- package/src/index.ts +257 -0
- package/src/native-compaction.ts +392 -0
- package/src/patch/apply.ts +338 -0
- package/src/patch/parser.ts +224 -0
- package/src/patch/render.ts +201 -0
- package/src/provider-native-tools.ts +75 -0
- package/src/providers/codex-apply-patch-tool.ts +23 -0
- package/src/providers/codex-apply-patch.lark +19 -0
- package/src/providers/openai-codex/cache-key.ts +52 -0
- package/src/providers/openai-codex/captured-stream.ts +50 -0
- package/src/providers/openai-codex/constants.ts +61 -0
- package/src/providers/openai-codex/continuation.ts +110 -0
- package/src/providers/openai-codex/errors.ts +130 -0
- package/src/providers/openai-codex/events.ts +123 -0
- package/src/providers/openai-codex/headers.ts +224 -0
- package/src/providers/openai-codex/lite.ts +24 -0
- package/src/providers/openai-codex/message.ts +33 -0
- package/src/providers/openai-codex/prewarm.ts +76 -0
- package/src/providers/openai-codex/proxy.ts +55 -0
- package/src/providers/openai-codex/reasoning.ts +54 -0
- package/src/providers/openai-codex/request-body.ts +149 -0
- package/src/providers/openai-codex/request-context.ts +20 -0
- package/src/providers/openai-codex/request-metadata.ts +137 -0
- package/src/providers/openai-codex/retry.ts +154 -0
- package/src/providers/openai-codex/runtime.ts +1 -0
- package/src/providers/openai-codex/sse.ts +93 -0
- package/src/providers/openai-codex/stream.ts +367 -0
- package/src/providers/openai-codex/urls.ts +24 -0
- package/src/providers/openai-codex/usage.ts +60 -0
- package/src/providers/openai-codex/websocket-connection.ts +216 -0
- package/src/providers/openai-codex/websocket-events.ts +210 -0
- package/src/providers/openai-codex/websocket-session.ts +192 -0
- package/src/providers/openai-codex/websocket-socket.ts +18 -0
- package/src/providers/openai-codex/websocket-stream.ts +151 -0
- package/src/tools/apply-patch.ts +84 -0
- 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
|
+
}
|