@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,210 @@
|
|
|
1
|
+
import { WEBSOCKET_EVENT_QUEUE_CAPACITY, WEBSOCKET_IDLE_TIMEOUT_MS, WEBSOCKET_SEND_TIMEOUT_MS } from "./constants.js";
|
|
2
|
+
import { ProviderProtocolError, extractWebSocketCloseError, extractWebSocketError } from "./errors.js";
|
|
3
|
+
import { type StreamEventShape, type WebSocketLike } from "@oai404iao/pi-codex-runtime/internal/providers/openai-codex/types";
|
|
4
|
+
|
|
5
|
+
export async function sendWebSocketRequest(
|
|
6
|
+
socket: WebSocketLike,
|
|
7
|
+
payload: string,
|
|
8
|
+
signal: AbortSignal | undefined,
|
|
9
|
+
timeoutMs = WEBSOCKET_SEND_TIMEOUT_MS,
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
12
|
+
await new Promise<void>((resolve, reject) => {
|
|
13
|
+
let settled = false;
|
|
14
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
15
|
+
const finish = (error?: Error) => {
|
|
16
|
+
if (settled) return;
|
|
17
|
+
settled = true;
|
|
18
|
+
if (timeout) clearTimeout(timeout);
|
|
19
|
+
signal?.removeEventListener("abort", onAbort);
|
|
20
|
+
if (error) reject(error);
|
|
21
|
+
else resolve();
|
|
22
|
+
};
|
|
23
|
+
const onAbort = () => finish(new Error("Request was aborted"));
|
|
24
|
+
timeout = setTimeout(
|
|
25
|
+
() => finish(new Error(`OpenAI Responses WebSocket send timed out after ${timeoutMs}ms`)),
|
|
26
|
+
Math.max(1, timeoutMs),
|
|
27
|
+
);
|
|
28
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
29
|
+
try {
|
|
30
|
+
socket.send(payload, (error?: Error) => {
|
|
31
|
+
if (error) {
|
|
32
|
+
finish(new Error(`Failed to send OpenAI Responses WebSocket request: ${error.message}`));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
finish();
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function* parseWebSocket(socket: WebSocketLike, signal: AbortSignal | undefined): AsyncIterable<StreamEventShape> {
|
|
44
|
+
const queue: StreamEventShape[] = [];
|
|
45
|
+
let pending: (() => void) | null = null;
|
|
46
|
+
let done = false;
|
|
47
|
+
let failed: Error | null = null;
|
|
48
|
+
let closeError: Error | null = null;
|
|
49
|
+
let sawCompletion = false;
|
|
50
|
+
let pendingMessages = 0;
|
|
51
|
+
let messageChain = Promise.resolve();
|
|
52
|
+
|
|
53
|
+
const wake = () => {
|
|
54
|
+
if (!pending) return;
|
|
55
|
+
const resolve = pending;
|
|
56
|
+
pending = null;
|
|
57
|
+
resolve();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const onMessage = (event: unknown) => {
|
|
61
|
+
if (done) return;
|
|
62
|
+
if (queue.length + pendingMessages >= WEBSOCKET_EVENT_QUEUE_CAPACITY) {
|
|
63
|
+
failed = new ProviderProtocolError(
|
|
64
|
+
`OpenAI Responses WebSocket event queue exceeded ${WEBSOCKET_EVENT_QUEUE_CAPACITY} items`,
|
|
65
|
+
);
|
|
66
|
+
done = true;
|
|
67
|
+
wake();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
pendingMessages++;
|
|
71
|
+
messageChain = messageChain
|
|
72
|
+
.then(async () => {
|
|
73
|
+
if (!event || typeof event !== "object" || !("data" in event)) return;
|
|
74
|
+
if ((event as { isBinary?: unknown }).isBinary === true) {
|
|
75
|
+
failed = new ProviderProtocolError("Unexpected binary OpenAI Responses WebSocket event");
|
|
76
|
+
done = true;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const data = (event as { data?: unknown }).data;
|
|
80
|
+
const text = typeof data === "string"
|
|
81
|
+
? data
|
|
82
|
+
: Buffer.isBuffer(data)
|
|
83
|
+
? data.toString("utf8")
|
|
84
|
+
: ArrayBuffer.isView(data)
|
|
85
|
+
? Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8")
|
|
86
|
+
: null;
|
|
87
|
+
if (text === null) {
|
|
88
|
+
failed = new ProviderProtocolError("Unsupported OpenAI Responses WebSocket message payload");
|
|
89
|
+
done = true;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(text) as StreamEventShape;
|
|
94
|
+
const type = typeof parsed.type === "string" ? parsed.type : "";
|
|
95
|
+
if (type === "response.completed" || type === "response.done" || type === "response.incomplete") {
|
|
96
|
+
sawCompletion = true;
|
|
97
|
+
closeError = null;
|
|
98
|
+
done = true;
|
|
99
|
+
}
|
|
100
|
+
if (queue.length >= WEBSOCKET_EVENT_QUEUE_CAPACITY) {
|
|
101
|
+
failed = new ProviderProtocolError(
|
|
102
|
+
`OpenAI Responses WebSocket event queue exceeded ${WEBSOCKET_EVENT_QUEUE_CAPACITY} items`,
|
|
103
|
+
);
|
|
104
|
+
done = true;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
queue.push(parsed);
|
|
108
|
+
} catch {
|
|
109
|
+
// Match Codex: malformed text frames are logged/ignored rather than
|
|
110
|
+
// tearing down an otherwise healthy response stream.
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
.catch((error: unknown) => {
|
|
114
|
+
failed = error instanceof Error ? error : new Error(String(error));
|
|
115
|
+
done = true;
|
|
116
|
+
})
|
|
117
|
+
.finally(() => {
|
|
118
|
+
pendingMessages--;
|
|
119
|
+
wake();
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const onError = (event: unknown) => {
|
|
124
|
+
failed = extractWebSocketError(event);
|
|
125
|
+
done = true;
|
|
126
|
+
wake();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const onClose = (event: unknown) => {
|
|
130
|
+
if (sawCompletion) {
|
|
131
|
+
done = true;
|
|
132
|
+
wake();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!closeError) {
|
|
136
|
+
closeError = extractWebSocketCloseError(event);
|
|
137
|
+
}
|
|
138
|
+
done = true;
|
|
139
|
+
wake();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const onAbort = () => {
|
|
143
|
+
failed = new Error("Request was aborted");
|
|
144
|
+
done = true;
|
|
145
|
+
wake();
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
socket.addEventListener("message", onMessage);
|
|
149
|
+
socket.addEventListener("error", onError);
|
|
150
|
+
socket.addEventListener("close", onClose);
|
|
151
|
+
signal?.addEventListener("abort", onAbort);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
while (true) {
|
|
155
|
+
if (signal?.aborted) {
|
|
156
|
+
throw new Error("Request was aborted");
|
|
157
|
+
}
|
|
158
|
+
if (queue.length > 0) {
|
|
159
|
+
yield queue.shift() as StreamEventShape;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (done && pendingMessages === 0) break;
|
|
163
|
+
await new Promise<void>((resolve, reject) => {
|
|
164
|
+
const timeout = setTimeout(() => {
|
|
165
|
+
pending = null;
|
|
166
|
+
reject(new Error(`OpenAI Responses WebSocket idle timeout after ${WEBSOCKET_IDLE_TIMEOUT_MS}ms`));
|
|
167
|
+
}, WEBSOCKET_IDLE_TIMEOUT_MS);
|
|
168
|
+
pending = () => {
|
|
169
|
+
clearTimeout(timeout);
|
|
170
|
+
resolve();
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (failed) throw failed;
|
|
176
|
+
if (closeError && !sawCompletion) throw closeError;
|
|
177
|
+
if (!sawCompletion) {
|
|
178
|
+
throw new Error("WebSocket stream closed before response.completed");
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
socket.removeEventListener("message", onMessage);
|
|
182
|
+
socket.removeEventListener("error", onError);
|
|
183
|
+
socket.removeEventListener("close", onClose);
|
|
184
|
+
signal?.removeEventListener("abort", onAbort);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function* startWebSocketOutputOnFirstEvent(
|
|
189
|
+
events: AsyncIterable<StreamEventShape>,
|
|
190
|
+
onStart: () => void,
|
|
191
|
+
): AsyncIterable<StreamEventShape> {
|
|
192
|
+
let started = false;
|
|
193
|
+
for await (const event of events) {
|
|
194
|
+
if (!started && event.type !== "error" && event.type !== "response.failed") {
|
|
195
|
+
started = true;
|
|
196
|
+
onStart();
|
|
197
|
+
}
|
|
198
|
+
yield event;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function* countWebSocketEvents(
|
|
203
|
+
events: AsyncIterable<StreamEventShape>,
|
|
204
|
+
onEvent: () => void,
|
|
205
|
+
): AsyncIterable<StreamEventShape> {
|
|
206
|
+
for await (const event of events) {
|
|
207
|
+
onEvent();
|
|
208
|
+
yield event;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { SESSION_WEBSOCKET_CACHE_TTL_MS } from "./constants.js";
|
|
2
|
+
import { type AcquiredWebSocket, type SessionWebSocketCacheEntry, type WebSocketAcquireWaiter } from "@oai404iao/pi-codex-runtime/internal/providers/openai-codex/types";
|
|
3
|
+
import { connectWebSocket } from "./websocket-connection.js";
|
|
4
|
+
import { closeWebSocketSilently, isWebSocketReusable } from "./websocket-socket.js";
|
|
5
|
+
|
|
6
|
+
export const websocketSessionCache = new Map<string, SessionWebSocketCacheEntry>();
|
|
7
|
+
|
|
8
|
+
const websocketConnectionPromises = new Map<string, Promise<SessionWebSocketCacheEntry>>();
|
|
9
|
+
|
|
10
|
+
export const websocketHttpFallbackSessions = new Set<string>();
|
|
11
|
+
|
|
12
|
+
export function closeProviderWebSocketSessions(sessionId?: string): void {
|
|
13
|
+
for (const cacheKey of websocketConnectionPromises.keys()) {
|
|
14
|
+
if (sessionId && !cacheKey.startsWith(`${sessionId}\n`)) continue;
|
|
15
|
+
websocketConnectionPromises.delete(cacheKey);
|
|
16
|
+
}
|
|
17
|
+
for (const [cacheKey, entry] of websocketSessionCache) {
|
|
18
|
+
if (sessionId && !cacheKey.startsWith(`${sessionId}\n`)) continue;
|
|
19
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
20
|
+
for (const waiter of entry.waiters.splice(0)) {
|
|
21
|
+
if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
22
|
+
waiter.reject(new Error("WebSocket session closed"));
|
|
23
|
+
}
|
|
24
|
+
closeWebSocketSilently(entry.socket, 1000, "session_shutdown");
|
|
25
|
+
websocketSessionCache.delete(cacheKey);
|
|
26
|
+
}
|
|
27
|
+
if (sessionId) {
|
|
28
|
+
for (const fallbackKey of websocketHttpFallbackSessions) {
|
|
29
|
+
if (fallbackKey.startsWith(`${sessionId}\n`)) websocketHttpFallbackSessions.delete(fallbackKey);
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
websocketHttpFallbackSessions.clear();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function scheduleSessionWebSocketExpiry(cacheKey: string, entry: SessionWebSocketCacheEntry): void {
|
|
37
|
+
if (entry.idleTimer) {
|
|
38
|
+
clearTimeout(entry.idleTimer);
|
|
39
|
+
}
|
|
40
|
+
entry.idleTimer = setTimeout(() => {
|
|
41
|
+
if (entry.busy || entry.waiters.length > 0) return;
|
|
42
|
+
closeWebSocketSilently(entry.socket, 1000, "idle_timeout");
|
|
43
|
+
websocketSessionCache.delete(cacheKey);
|
|
44
|
+
}, SESSION_WEBSOCKET_CACHE_TTL_MS);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function removeWebSocketWaiter(entry: SessionWebSocketCacheEntry, waiter: WebSocketAcquireWaiter): void {
|
|
48
|
+
const index = entry.waiters.indexOf(waiter);
|
|
49
|
+
if (index >= 0) entry.waiters.splice(index, 1);
|
|
50
|
+
if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function acquireCachedWebSocketEntry(
|
|
54
|
+
cacheKey: string,
|
|
55
|
+
entry: SessionWebSocketCacheEntry,
|
|
56
|
+
reused: boolean,
|
|
57
|
+
): AcquiredWebSocket {
|
|
58
|
+
entry.busy = true;
|
|
59
|
+
let released = false;
|
|
60
|
+
const release = ({ keep } = {} as { keep?: boolean }) => {
|
|
61
|
+
if (released) return;
|
|
62
|
+
const reusable = keep !== false && isWebSocketReusable(entry.socket);
|
|
63
|
+
if (!reusable) {
|
|
64
|
+
released = true;
|
|
65
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
66
|
+
closeWebSocketSilently(entry.socket);
|
|
67
|
+
if (websocketSessionCache.get(cacheKey) === entry) {
|
|
68
|
+
websocketSessionCache.delete(cacheKey);
|
|
69
|
+
}
|
|
70
|
+
for (const waiter of entry.waiters.splice(0)) {
|
|
71
|
+
if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
72
|
+
waiter.reject(new Error("WebSocket connection became unavailable"));
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
while (entry.waiters.length > 0) {
|
|
78
|
+
const waiter = entry.waiters.shift()!;
|
|
79
|
+
if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
80
|
+
if (waiter.signal?.aborted) {
|
|
81
|
+
waiter.reject(new Error("Request was aborted"));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
released = true;
|
|
85
|
+
waiter.resolve(acquireCachedWebSocketEntry(cacheKey, entry, true));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
entry.busy = false;
|
|
90
|
+
released = true;
|
|
91
|
+
scheduleSessionWebSocketExpiry(cacheKey, entry);
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
socket: entry.socket,
|
|
95
|
+
entry,
|
|
96
|
+
reused,
|
|
97
|
+
release,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function waitForCachedWebSocket(
|
|
102
|
+
cacheKey: string,
|
|
103
|
+
entry: SessionWebSocketCacheEntry,
|
|
104
|
+
signal: AbortSignal | undefined,
|
|
105
|
+
): Promise<AcquiredWebSocket> {
|
|
106
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
107
|
+
return new Promise<AcquiredWebSocket>((resolve, reject) => {
|
|
108
|
+
const waiter: WebSocketAcquireWaiter = {
|
|
109
|
+
resolve,
|
|
110
|
+
reject,
|
|
111
|
+
...(signal ? { signal } : {}),
|
|
112
|
+
};
|
|
113
|
+
if (signal) {
|
|
114
|
+
waiter.onAbort = () => {
|
|
115
|
+
removeWebSocketWaiter(entry, waiter);
|
|
116
|
+
reject(new Error("Request was aborted"));
|
|
117
|
+
};
|
|
118
|
+
signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
119
|
+
}
|
|
120
|
+
entry.waiters.push(waiter);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function acquireWebSocket(
|
|
125
|
+
url: string,
|
|
126
|
+
headers: Headers,
|
|
127
|
+
cacheKey: string | undefined,
|
|
128
|
+
sessionId: string | undefined,
|
|
129
|
+
signal: AbortSignal | undefined,
|
|
130
|
+
connectTimeoutMs: number,
|
|
131
|
+
): Promise<AcquiredWebSocket> {
|
|
132
|
+
if (!cacheKey || !sessionId) {
|
|
133
|
+
const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs);
|
|
134
|
+
return {
|
|
135
|
+
socket,
|
|
136
|
+
reused: false,
|
|
137
|
+
release: ({ keep } = {}) => {
|
|
138
|
+
if (keep === false) {
|
|
139
|
+
closeWebSocketSilently(socket);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
closeWebSocketSilently(socket);
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const cached = websocketSessionCache.get(cacheKey);
|
|
148
|
+
if (cached) {
|
|
149
|
+
if (cached.idleTimer) {
|
|
150
|
+
clearTimeout(cached.idleTimer);
|
|
151
|
+
cached.idleTimer = undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!cached.busy && isWebSocketReusable(cached.socket)) {
|
|
155
|
+
return acquireCachedWebSocketEntry(cacheKey, cached, true);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (cached.busy) {
|
|
159
|
+
return waitForCachedWebSocket(cacheKey, cached, signal);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!isWebSocketReusable(cached.socket)) {
|
|
163
|
+
closeWebSocketSilently(cached.socket);
|
|
164
|
+
websocketSessionCache.delete(cacheKey);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let pendingConnection = websocketConnectionPromises.get(cacheKey);
|
|
169
|
+
if (!pendingConnection) {
|
|
170
|
+
let connectionPromise!: Promise<SessionWebSocketCacheEntry>;
|
|
171
|
+
connectionPromise = connectWebSocket(url, headers, signal, connectTimeoutMs)
|
|
172
|
+
.then((socket) => {
|
|
173
|
+
if (websocketConnectionPromises.get(cacheKey) !== connectionPromise) {
|
|
174
|
+
closeWebSocketSilently(socket, 1000, "session_shutdown");
|
|
175
|
+
throw new Error("WebSocket session closed");
|
|
176
|
+
}
|
|
177
|
+
const entry: SessionWebSocketCacheEntry = { socket, busy: false, waiters: [] };
|
|
178
|
+
websocketSessionCache.set(cacheKey, entry);
|
|
179
|
+
return entry;
|
|
180
|
+
})
|
|
181
|
+
.finally(() => {
|
|
182
|
+
if (websocketConnectionPromises.get(cacheKey) === connectionPromise) {
|
|
183
|
+
websocketConnectionPromises.delete(cacheKey);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
websocketConnectionPromises.set(cacheKey, connectionPromise);
|
|
187
|
+
pendingConnection = connectionPromise;
|
|
188
|
+
}
|
|
189
|
+
const entry = await pendingConnection;
|
|
190
|
+
if (entry.busy) return waitForCachedWebSocket(cacheKey, entry, signal);
|
|
191
|
+
return acquireCachedWebSocketEntry(cacheKey, entry, false);
|
|
192
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type WebSocketLike } from "@oai404iao/pi-codex-runtime/internal/providers/openai-codex/types";
|
|
2
|
+
|
|
3
|
+
function getWebSocketReadyState(socket: WebSocketLike): number | undefined {
|
|
4
|
+
return typeof socket.readyState === "number" ? socket.readyState : undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isWebSocketReusable(socket: WebSocketLike): boolean {
|
|
8
|
+
const readyState = getWebSocketReadyState(socket);
|
|
9
|
+
return readyState === undefined || readyState === 1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function closeWebSocketSilently(socket: WebSocketLike, code = 1000, reason = "done"): void {
|
|
13
|
+
try {
|
|
14
|
+
socket.close(code, reason);
|
|
15
|
+
} catch {
|
|
16
|
+
// ignore close errors
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { type Api, type AssistantMessage, type AssistantMessageEventStream, type Model, type SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
import { type CitationSource, type WebSearchCitationSource } from "@oai404iao/pi-codex-runtime/internal/providers/responses/types";
|
|
3
|
+
import { webSocketCacheKey } from "./cache-key.js";
|
|
4
|
+
import { processCapturedResponsesStream } from "./captured-stream.js";
|
|
5
|
+
import type { ProviderStreamEffects } from "@oai404iao/pi-codex-runtime/internal/providers/openai-codex/stream-effects";
|
|
6
|
+
import { WEBSOCKET_CONNECT_TIMEOUT_MS, WEBSOCKET_SEND_TIMEOUT_MS } from "./constants.js";
|
|
7
|
+
import { buildCachedWebSocketRequestBody, prepareWebSocketRequestBodyForWire } from "./continuation.js";
|
|
8
|
+
import { withWebSocketRequestMetadata } from "./request-metadata.js";
|
|
9
|
+
import { isPreviousResponseNotFoundError, isRetryableEarlyWebSocketError } from "./retry.js";
|
|
10
|
+
import { type ResponsesBody, type WebSocketRequestMetadata } from "@oai404iao/pi-codex-runtime/internal/providers/openai-codex/types";
|
|
11
|
+
import { countWebSocketEvents, parseWebSocket, sendWebSocketRequest, startWebSocketOutputOnFirstEvent } from "./websocket-events.js";
|
|
12
|
+
import { acquireWebSocket } from "./websocket-session.js";
|
|
13
|
+
|
|
14
|
+
export async function processWebSocketStream<TApi extends Api>(
|
|
15
|
+
url: string,
|
|
16
|
+
body: ResponsesBody,
|
|
17
|
+
headers: Headers,
|
|
18
|
+
output: AssistantMessage,
|
|
19
|
+
stream: AssistantMessageEventStream,
|
|
20
|
+
model: Model<TApi>,
|
|
21
|
+
onStart: () => void,
|
|
22
|
+
options: SimpleStreamOptions | undefined,
|
|
23
|
+
deps: ProviderStreamEffects,
|
|
24
|
+
cwd: string,
|
|
25
|
+
requestPrompt: string | undefined,
|
|
26
|
+
webSearchCitationSources: ReadonlyArray<WebSearchCitationSource>,
|
|
27
|
+
historicalCitationSources: ReadonlyArray<CitationSource>,
|
|
28
|
+
requestMetadata: WebSocketRequestMetadata,
|
|
29
|
+
profileHash?: string,
|
|
30
|
+
startupPrewarm?: Promise<void>,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
let streamStarted = false;
|
|
33
|
+
let disableCachedContext = false;
|
|
34
|
+
let staleSocketRetried = false;
|
|
35
|
+
let missingPreviousResponseRetried = false;
|
|
36
|
+
|
|
37
|
+
while (true) {
|
|
38
|
+
if (startupPrewarm) {
|
|
39
|
+
await startupPrewarm;
|
|
40
|
+
startupPrewarm = undefined;
|
|
41
|
+
}
|
|
42
|
+
const cacheKey = webSocketCacheKey(
|
|
43
|
+
options?.sessionId,
|
|
44
|
+
model as Model<Api>,
|
|
45
|
+
url,
|
|
46
|
+
headers,
|
|
47
|
+
profileHash,
|
|
48
|
+
);
|
|
49
|
+
const { socket, entry, release, reused } = await acquireWebSocket(
|
|
50
|
+
url,
|
|
51
|
+
headers,
|
|
52
|
+
cacheKey,
|
|
53
|
+
options?.sessionId,
|
|
54
|
+
options?.signal,
|
|
55
|
+
WEBSOCKET_CONNECT_TIMEOUT_MS,
|
|
56
|
+
);
|
|
57
|
+
let keepConnection = true;
|
|
58
|
+
let released = false;
|
|
59
|
+
let eventCount = 0;
|
|
60
|
+
// Continuation is safe only when buildCachedWebSocketRequestBody proves
|
|
61
|
+
// that this request exactly extends the cached logical request.
|
|
62
|
+
const useCachedContext = true;
|
|
63
|
+
// ChatGPT Codex Responses rejects `store: true` ("Store must be set to false").
|
|
64
|
+
// WebSocket continuation still works via connection-scoped previous_response_id state.
|
|
65
|
+
const fullBody = withWebSocketRequestMetadata(body, requestMetadata);
|
|
66
|
+
const requestBody = useCachedContext && !disableCachedContext && entry
|
|
67
|
+
? buildCachedWebSocketRequestBody(entry, fullBody)
|
|
68
|
+
: fullBody;
|
|
69
|
+
const wireRequestBody = prepareWebSocketRequestBodyForWire(requestBody);
|
|
70
|
+
|
|
71
|
+
const releaseOnce = (releaseOptions?: { keep?: boolean }) => {
|
|
72
|
+
if (released) return;
|
|
73
|
+
released = true;
|
|
74
|
+
release(releaseOptions);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await sendWebSocketRequest(
|
|
79
|
+
socket,
|
|
80
|
+
JSON.stringify({ type: "response.create", ...wireRequestBody }),
|
|
81
|
+
options?.signal,
|
|
82
|
+
WEBSOCKET_SEND_TIMEOUT_MS,
|
|
83
|
+
);
|
|
84
|
+
const startOutput = () => {
|
|
85
|
+
if (streamStarted) return;
|
|
86
|
+
onStart();
|
|
87
|
+
stream.push({ type: "start", partial: output });
|
|
88
|
+
streamStarted = true;
|
|
89
|
+
};
|
|
90
|
+
const continuationResult = await processCapturedResponsesStream(
|
|
91
|
+
startWebSocketOutputOnFirstEvent(
|
|
92
|
+
countWebSocketEvents(parseWebSocket(socket, options?.signal), () => {
|
|
93
|
+
eventCount++;
|
|
94
|
+
}),
|
|
95
|
+
startOutput,
|
|
96
|
+
),
|
|
97
|
+
output,
|
|
98
|
+
stream,
|
|
99
|
+
model,
|
|
100
|
+
options,
|
|
101
|
+
options?.sessionId,
|
|
102
|
+
deps,
|
|
103
|
+
cwd,
|
|
104
|
+
requestPrompt,
|
|
105
|
+
webSearchCitationSources,
|
|
106
|
+
historicalCitationSources,
|
|
107
|
+
);
|
|
108
|
+
if (options?.signal?.aborted) {
|
|
109
|
+
keepConnection = false;
|
|
110
|
+
} else if (entry && continuationResult.responseId) {
|
|
111
|
+
entry.continuation = {
|
|
112
|
+
lastRequestBody: fullBody,
|
|
113
|
+
lastResponseId: continuationResult.responseId,
|
|
114
|
+
lastResponseItems: continuationResult.responseItems,
|
|
115
|
+
};
|
|
116
|
+
} else if (entry) {
|
|
117
|
+
entry.continuation = undefined;
|
|
118
|
+
}
|
|
119
|
+
releaseOnce({ keep: keepConnection });
|
|
120
|
+
return;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (entry) {
|
|
123
|
+
entry.continuation = undefined;
|
|
124
|
+
}
|
|
125
|
+
keepConnection = false;
|
|
126
|
+
releaseOnce({ keep: false });
|
|
127
|
+
// Pi's stock provider reuses session WebSockets. In practice the Codex
|
|
128
|
+
// backend sometimes cleanly closes an idle cached socket between turns;
|
|
129
|
+
// if that stale socket fails before any response event, retry once on a
|
|
130
|
+
// fresh WebSocket without changing request shape or falling back transports.
|
|
131
|
+
if (!staleSocketRetried && reused && eventCount === 0 && !options?.signal?.aborted && isRetryableEarlyWebSocketError(error)) {
|
|
132
|
+
staleSocketRetried = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (
|
|
136
|
+
!missingPreviousResponseRetried
|
|
137
|
+
&& requestBody.previous_response_id
|
|
138
|
+
&& !streamStarted
|
|
139
|
+
&& !options?.signal?.aborted
|
|
140
|
+
&& isPreviousResponseNotFoundError(error)
|
|
141
|
+
) {
|
|
142
|
+
missingPreviousResponseRetried = true;
|
|
143
|
+
disableCachedContext = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
} finally {
|
|
148
|
+
releaseOnce({ keep: keepConnection });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { applyPatch, resolvePatchPath, type ApplyPatchResult } from "../patch/apply.js";
|
|
2
|
+
import { parseApplyPatch } from "../patch/parser.js";
|
|
3
|
+
import { createApplyPatchRenderers } from "../patch/render.js";
|
|
4
|
+
|
|
5
|
+
export interface ApplyPatchInput {
|
|
6
|
+
input: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const applyPatchToolSchema = {
|
|
10
|
+
type: "object",
|
|
11
|
+
additionalProperties: false,
|
|
12
|
+
properties: {
|
|
13
|
+
input: { type: "string", description: "Raw Codex patch text. File paths may be relative or absolute. Add File content lines start with +; Update File chunks use context, +, and - lines." },
|
|
14
|
+
},
|
|
15
|
+
required: ["input"],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function applyPatchTargetPaths(input: string, cwd: string): string[] {
|
|
19
|
+
const parsed = parseApplyPatch(input);
|
|
20
|
+
const paths = new Set<string>();
|
|
21
|
+
for (const action of parsed.actions) {
|
|
22
|
+
paths.add(resolvePatchPath(action.path, { cwd }));
|
|
23
|
+
if (action.kind === "update" && action.moveTo) paths.add(resolvePatchPath(action.moveTo, { cwd }));
|
|
24
|
+
}
|
|
25
|
+
return [...paths].sort();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function withMutationQueue(path: string, fn: () => Promise<void>): Promise<void> {
|
|
29
|
+
try {
|
|
30
|
+
const mod = await import("@earendil-works/pi-coding-agent");
|
|
31
|
+
const queue = (mod as { withFileMutationQueue?: (path: string, fn: () => Promise<void>) => Promise<void> }).withFileMutationQueue;
|
|
32
|
+
if (typeof queue === "function") return queue(path, fn);
|
|
33
|
+
} catch {
|
|
34
|
+
// Unit tests can run outside Pi without peer dependencies installed.
|
|
35
|
+
}
|
|
36
|
+
return fn();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function executeApplyPatchTool(params: ApplyPatchInput, cwd: string): Promise<{ content: Array<{ type: "text"; text: string }>; details: ApplyPatchResult }> {
|
|
40
|
+
if (!params || typeof params.input !== "string") throw new Error("apply_patch requires an input string.");
|
|
41
|
+
let targets: string[];
|
|
42
|
+
try {
|
|
43
|
+
targets = applyPatchTargetPaths(params.input, cwd);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
46
|
+
throw new Error(`apply_patch verification failed: ${message}`);
|
|
47
|
+
}
|
|
48
|
+
let result: ApplyPatchResult | undefined;
|
|
49
|
+
const runAt = async (index: number): Promise<void> => {
|
|
50
|
+
if (index >= targets.length) {
|
|
51
|
+
result = await applyPatch(params.input, { cwd });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await withMutationQueue(targets[index]!, () => runAt(index + 1));
|
|
55
|
+
};
|
|
56
|
+
await runAt(0);
|
|
57
|
+
if (!result) throw new Error("apply_patch did not produce a result.");
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text", text: result.summary }],
|
|
60
|
+
details: result,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createApplyPatchToolDefinition(options: { cwd?: string; deferRendering?: boolean } = {}) {
|
|
65
|
+
const definition: Record<string, unknown> = {
|
|
66
|
+
renderShell: "self",
|
|
67
|
+
name: "apply_patch",
|
|
68
|
+
label: "Apply Patch",
|
|
69
|
+
description: "Use apply_patch to edit files with the Codex patch format. A patch starts with *** Begin Patch, contains one or more Add, Update, or Delete file sections, and ends with *** End Patch.",
|
|
70
|
+
promptSnippet: "Apply Codex-style multi-file patches with contextual update hunks and explicit Add, Update, or Delete headers.",
|
|
71
|
+
promptGuidelines: [
|
|
72
|
+
"Use apply_patch for concise multi-file edits when a Codex-style patch is clearer than separate edit/write calls.",
|
|
73
|
+
"In apply_patch, paths may be relative to the current working directory or absolute; prefix every Add File content line with +; and use @@ class/function context plus surrounding lines when repeated code needs disambiguation.",
|
|
74
|
+
"Use *** End of File in apply_patch when a hunk must match the end of a file.",
|
|
75
|
+
],
|
|
76
|
+
parameters: applyPatchToolSchema,
|
|
77
|
+
async execute(_toolCallId: string, params: ApplyPatchInput, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: { cwd: string }) {
|
|
78
|
+
const cwd = ctx?.cwd ?? options.cwd ?? process.cwd();
|
|
79
|
+
return executeApplyPatchTool(params, cwd);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
if (!options.deferRendering) Object.assign(definition, createApplyPatchRenderers());
|
|
83
|
+
return definition;
|
|
84
|
+
}
|