@bitkyc08/opencodex 0.2.2 → 1.9.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/README.md +3 -1
- package/gui/dist/assets/{index-Dt5t57MW.js → index-CDhJ0DI7.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +3 -1
- package/src/abort.ts +29 -0
- package/src/adapters/anthropic.ts +15 -5
- package/src/adapters/google.ts +27 -11
- package/src/adapters/openai-chat.ts +38 -12
- package/src/adapters/openai-responses.ts +18 -1
- package/src/bridge.ts +155 -17
- package/src/cli.ts +0 -0
- package/src/codex-catalog.ts +102 -11
- package/src/codex-inject.ts +47 -4
- package/src/config.ts +5 -0
- package/src/debug.ts +10 -0
- package/src/errors.ts +47 -0
- package/src/generated/jawcode-model-metadata.ts +69 -0
- package/src/init.ts +5 -32
- package/src/oauth/index.ts +19 -33
- package/src/oauth/key-providers.ts +2 -63
- package/src/providers/derive.ts +163 -0
- package/src/providers/registry.ts +140 -0
- package/src/responses/parser.ts +6 -1
- package/src/server.ts +182 -9
- package/src/types.ts +6 -0
- package/src/vision/describe.ts +6 -1
- package/src/vision/index.ts +2 -1
- package/src/web-search/executor.ts +6 -1
- package/src/web-search/loop.ts +9 -3
- package/src/ws-bridge.ts +359 -0
package/gui/dist/index.html
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<meta name="color-scheme" content="dark" />
|
|
8
8
|
<title>opencodex · proxy dashboard</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-CDhJ0DI7.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/assets/index-C1wlp1SM.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitkyc08/opencodex",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"dev": "bun run src/cli.ts start",
|
|
23
23
|
"start": "bun run src/cli.ts start",
|
|
24
|
+
"test": "bun test tests",
|
|
24
25
|
"typecheck": "bun x tsc --noEmit",
|
|
26
|
+
"generate:jawcode-metadata": "bun scripts/generate-jawcode-metadata.ts",
|
|
25
27
|
"build:gui": "cd gui && bun install && bun run build",
|
|
26
28
|
"postinstall": "node scripts/postinstall.mjs",
|
|
27
29
|
"prepublishOnly": "bun run typecheck && bun run build:gui",
|
package/src/abort.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface LinkedAbortSignal {
|
|
2
|
+
signal: AbortSignal;
|
|
3
|
+
cleanup: () => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function signalWithTimeout(timeoutMs: number, parent?: AbortSignal): LinkedAbortSignal {
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
const timeout = setTimeout(() => {
|
|
9
|
+
if (!controller.signal.aborted) controller.abort(new DOMException("Timeout elapsed", "TimeoutError"));
|
|
10
|
+
}, timeoutMs);
|
|
11
|
+
|
|
12
|
+
const abortFromParent = () => {
|
|
13
|
+
if (!controller.signal.aborted) controller.abort(parent?.reason);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
if (parent?.aborted) {
|
|
17
|
+
abortFromParent();
|
|
18
|
+
} else {
|
|
19
|
+
parent?.addEventListener("abort", abortFromParent, { once: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
signal: controller.signal,
|
|
24
|
+
cleanup: () => {
|
|
25
|
+
clearTimeout(timeout);
|
|
26
|
+
parent?.removeEventListener("abort", abortFromParent);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ProviderAdapter } from "./base";
|
|
2
|
+
import { debugDroppedFrame } from "../debug";
|
|
2
3
|
import type {
|
|
3
4
|
AdapterEvent,
|
|
4
5
|
OcxAssistantMessage,
|
|
@@ -9,6 +10,7 @@ import type {
|
|
|
9
10
|
OcxTextContent,
|
|
10
11
|
OcxThinkingContent,
|
|
11
12
|
OcxToolCall,
|
|
13
|
+
OcxUsage,
|
|
12
14
|
} from "../types";
|
|
13
15
|
import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION, applyClaudeToolPrefix, stripClaudeToolPrefix } from "../oauth/anthropic";
|
|
14
16
|
import { parseDataUrl } from "./image";
|
|
@@ -48,6 +50,16 @@ function reasoningBudget(effort: string): number {
|
|
|
48
50
|
}
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
function usageFromAnthropic(usage: Record<string, number> | undefined): OcxUsage | undefined {
|
|
54
|
+
if (!usage) return undefined;
|
|
55
|
+
const hasCache = usage.cache_read_input_tokens !== undefined || usage.cache_creation_input_tokens !== undefined;
|
|
56
|
+
return {
|
|
57
|
+
inputTokens: usage.input_tokens ?? 0,
|
|
58
|
+
outputTokens: usage.output_tokens ?? 0,
|
|
59
|
+
...(hasCache ? { cachedInputTokens: (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
function messagesToAnthropicFormat(parsed: OcxParsedRequest, isOAuth: boolean): { system: string | undefined; messages: unknown[] } {
|
|
52
64
|
const system = parsed.context.systemPrompt?.join("\n\n") || undefined;
|
|
53
65
|
const messages: unknown[] = [];
|
|
@@ -215,6 +227,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
|
|
|
215
227
|
try {
|
|
216
228
|
data = JSON.parse(payload) as Record<string, unknown>;
|
|
217
229
|
} catch {
|
|
230
|
+
debugDroppedFrame("anthropic", payload);
|
|
218
231
|
continue;
|
|
219
232
|
}
|
|
220
233
|
|
|
@@ -255,10 +268,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
|
|
|
255
268
|
if (usage) {
|
|
256
269
|
yield {
|
|
257
270
|
type: "done",
|
|
258
|
-
usage:
|
|
259
|
-
inputTokens: usage.input_tokens ?? 0,
|
|
260
|
-
outputTokens: usage.output_tokens ?? 0,
|
|
261
|
-
},
|
|
271
|
+
usage: usageFromAnthropic(usage),
|
|
262
272
|
};
|
|
263
273
|
}
|
|
264
274
|
break;
|
|
@@ -298,7 +308,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
|
|
|
298
308
|
const usage = json.usage as Record<string, number> | undefined;
|
|
299
309
|
events.push({
|
|
300
310
|
type: "done",
|
|
301
|
-
usage: usage
|
|
311
|
+
usage: usageFromAnthropic(usage),
|
|
302
312
|
});
|
|
303
313
|
return events;
|
|
304
314
|
},
|
package/src/adapters/google.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ProviderAdapter } from "./base";
|
|
2
|
+
import { debugDroppedFrame } from "../debug";
|
|
2
3
|
import type {
|
|
3
4
|
AdapterEvent,
|
|
4
5
|
OcxAssistantMessage,
|
|
@@ -7,6 +8,7 @@ import type {
|
|
|
7
8
|
OcxProviderConfig,
|
|
8
9
|
OcxTextContent,
|
|
9
10
|
OcxToolCall,
|
|
11
|
+
OcxUsage,
|
|
10
12
|
} from "../types";
|
|
11
13
|
import { contentPartsToText, parseDataUrl } from "./image";
|
|
12
14
|
|
|
@@ -74,6 +76,16 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined {
|
|
|
74
76
|
}];
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
function usageFromGemini(usage: Record<string, number> | undefined): OcxUsage | undefined {
|
|
80
|
+
if (!usage) return undefined;
|
|
81
|
+
return {
|
|
82
|
+
inputTokens: usage.promptTokenCount ?? 0,
|
|
83
|
+
outputTokens: usage.candidatesTokenCount ?? 0,
|
|
84
|
+
...(usage.cachedContentTokenCount !== undefined ? { cachedInputTokens: usage.cachedContentTokenCount } : {}),
|
|
85
|
+
...(usage.thoughtsTokenCount !== undefined ? { reasoningOutputTokens: usage.thoughtsTokenCount } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter {
|
|
78
90
|
return {
|
|
79
91
|
name: "google",
|
|
@@ -113,6 +125,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
113
125
|
const reader = response.body.getReader();
|
|
114
126
|
const decoder = new TextDecoder();
|
|
115
127
|
let buffer = "";
|
|
128
|
+
let pendingUsage: OcxUsage | undefined;
|
|
116
129
|
|
|
117
130
|
try {
|
|
118
131
|
while (true) {
|
|
@@ -129,7 +142,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
129
142
|
if (!payload) continue;
|
|
130
143
|
|
|
131
144
|
let chunk: Record<string, unknown>;
|
|
132
|
-
try { chunk = JSON.parse(payload); } catch { continue; }
|
|
145
|
+
try { chunk = JSON.parse(payload); } catch { debugDroppedFrame("google", payload); continue; }
|
|
146
|
+
|
|
147
|
+
// Inline provider error inside a 200 stream → terminal error (see openai-chat.ts).
|
|
148
|
+
if (chunk.error) {
|
|
149
|
+
const err = chunk.error as { message?: string } | undefined;
|
|
150
|
+
yield { type: "error", message: err?.message ?? "upstream error" };
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
133
153
|
|
|
134
154
|
const candidates = chunk.candidates as { content?: { parts?: unknown[] }; finishReason?: string }[] | undefined;
|
|
135
155
|
if (!candidates?.length) continue;
|
|
@@ -150,18 +170,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
150
170
|
}
|
|
151
171
|
|
|
152
172
|
const usageMeta = chunk.usageMetadata as Record<string, number> | undefined;
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
inputTokens: usageMeta.promptTokenCount ?? 0,
|
|
158
|
-
outputTokens: usageMeta.candidatesTokenCount ?? 0,
|
|
159
|
-
},
|
|
160
|
-
};
|
|
173
|
+
if (usageMeta) {
|
|
174
|
+
// Accumulate usage; emit a single terminal `done` post-loop so usage is never
|
|
175
|
+
// dropped on EOF and the stream never yields two `done` events.
|
|
176
|
+
pendingUsage = usageFromGemini(usageMeta);
|
|
161
177
|
}
|
|
162
178
|
}
|
|
163
179
|
}
|
|
164
|
-
yield { type: "done" };
|
|
180
|
+
yield { type: "done", usage: pendingUsage };
|
|
165
181
|
} finally {
|
|
166
182
|
reader.releaseLock();
|
|
167
183
|
}
|
|
@@ -187,7 +203,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
187
203
|
const usage = json.usageMetadata as Record<string, number> | undefined;
|
|
188
204
|
events.push({
|
|
189
205
|
type: "done",
|
|
190
|
-
usage: usage
|
|
206
|
+
usage: usageFromGemini(usage),
|
|
191
207
|
});
|
|
192
208
|
return events;
|
|
193
209
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ProviderAdapter } from "./base";
|
|
2
|
-
import
|
|
2
|
+
import { debugDroppedFrame } from "../debug";
|
|
3
|
+
import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxToolCall, OcxUsage } from "../types";
|
|
3
4
|
import { namespacedToolName } from "../types";
|
|
4
5
|
import { contentPartsToText } from "./image";
|
|
5
6
|
|
|
@@ -96,6 +97,18 @@ function toolChoiceToChatFormat(tc: OcxParsedRequest["options"]["toolChoice"]):
|
|
|
96
97
|
return undefined;
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
function usageFromOpenAIChat(usage: Record<string, unknown> | undefined): OcxUsage | undefined {
|
|
101
|
+
if (!usage) return undefined;
|
|
102
|
+
const promptDetails = usage.prompt_tokens_details as Record<string, number> | undefined;
|
|
103
|
+
const completionDetails = usage.completion_tokens_details as Record<string, number> | undefined;
|
|
104
|
+
return {
|
|
105
|
+
inputTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0,
|
|
106
|
+
outputTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0,
|
|
107
|
+
...(promptDetails?.cached_tokens !== undefined ? { cachedInputTokens: promptDetails.cached_tokens } : {}),
|
|
108
|
+
...(completionDetails?.reasoning_tokens !== undefined ? { reasoningOutputTokens: completionDetails.reasoning_tokens } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
99
112
|
export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
|
|
100
113
|
return {
|
|
101
114
|
name: "openai-chat",
|
|
@@ -151,7 +164,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
151
164
|
let buffer = "";
|
|
152
165
|
let currentToolCallId = "";
|
|
153
166
|
let currentToolCallName = "";
|
|
154
|
-
let pendingUsage:
|
|
167
|
+
let pendingUsage: OcxUsage | undefined;
|
|
155
168
|
|
|
156
169
|
try {
|
|
157
170
|
while (true) {
|
|
@@ -178,16 +191,25 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
178
191
|
try {
|
|
179
192
|
chunk = JSON.parse(payload) as Record<string, unknown>;
|
|
180
193
|
} catch {
|
|
194
|
+
debugDroppedFrame("openai-chat", payload);
|
|
181
195
|
continue;
|
|
182
196
|
}
|
|
183
197
|
|
|
198
|
+
// A 200/OK chat-completions stream may carry an inline provider error envelope
|
|
199
|
+
// instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
|
|
200
|
+
// classified response.failed (bridge case "error") — never a truncated completion.
|
|
201
|
+
if (chunk.error) {
|
|
202
|
+
const err = chunk.error as { message?: string } | undefined;
|
|
203
|
+
if (currentToolCallId) yield { type: "tool_call_end" };
|
|
204
|
+
yield { type: "error", message: err?.message ?? "upstream error" };
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
184
208
|
if (chunk.usage) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
};
|
|
190
|
-
continue;
|
|
209
|
+
// Record usage but keep parsing: some providers send usage and the final content
|
|
210
|
+
// delta in the SAME chunk; a `continue` here would drop that content. The choices
|
|
211
|
+
// guard below no-ops a usage-only chunk.
|
|
212
|
+
pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
|
|
191
213
|
}
|
|
192
214
|
|
|
193
215
|
const choices = chunk.choices as { delta?: Record<string, unknown>; finish_reason?: string }[] | undefined;
|
|
@@ -200,7 +222,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
200
222
|
}
|
|
201
223
|
|
|
202
224
|
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
|
|
203
|
-
yield { type: "
|
|
225
|
+
yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
|
|
204
226
|
}
|
|
205
227
|
|
|
206
228
|
const toolCalls = delta.tool_calls as { index: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
|
|
@@ -228,7 +250,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
228
250
|
if (currentToolCallId) {
|
|
229
251
|
yield { type: "tool_call_end" };
|
|
230
252
|
}
|
|
231
|
-
|
|
253
|
+
// EOF without a [DONE] sentinel: still surface any usage accumulated mid-stream.
|
|
254
|
+
yield { type: "done", usage: pendingUsage };
|
|
232
255
|
} finally {
|
|
233
256
|
reader.releaseLock();
|
|
234
257
|
}
|
|
@@ -244,6 +267,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
244
267
|
if (typeof msg.content === "string") {
|
|
245
268
|
events.push({ type: "text_delta", text: msg.content });
|
|
246
269
|
}
|
|
270
|
+
if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) {
|
|
271
|
+
events.push({ type: "reasoning_raw_delta", text: msg.reasoning_content });
|
|
272
|
+
}
|
|
247
273
|
const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
|
|
248
274
|
if (toolCalls) {
|
|
249
275
|
for (const tc of toolCalls) {
|
|
@@ -254,10 +280,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
254
280
|
}
|
|
255
281
|
}
|
|
256
282
|
}
|
|
257
|
-
const usage = json.usage as Record<string,
|
|
283
|
+
const usage = json.usage as Record<string, unknown> | undefined;
|
|
258
284
|
events.push({
|
|
259
285
|
type: "done",
|
|
260
|
-
usage: usage
|
|
286
|
+
usage: usageFromOpenAIChat(usage),
|
|
261
287
|
});
|
|
262
288
|
return events;
|
|
263
289
|
},
|
|
@@ -3,7 +3,24 @@ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types
|
|
|
3
3
|
|
|
4
4
|
// Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode.
|
|
5
5
|
// Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call.
|
|
6
|
-
export const FORWARD_HEADERS = [
|
|
6
|
+
export const FORWARD_HEADERS = [
|
|
7
|
+
"authorization",
|
|
8
|
+
"chatgpt-account-id",
|
|
9
|
+
"openai-beta",
|
|
10
|
+
"originator",
|
|
11
|
+
"session_id",
|
|
12
|
+
"session-id",
|
|
13
|
+
"thread-id",
|
|
14
|
+
"x-client-request-id",
|
|
15
|
+
"x-codex-beta-features",
|
|
16
|
+
"x-codex-installation-id",
|
|
17
|
+
"x-codex-parent-thread-id",
|
|
18
|
+
"x-codex-turn-metadata",
|
|
19
|
+
"x-codex-turn-state",
|
|
20
|
+
"x-codex-window-id",
|
|
21
|
+
"x-oai-attestation",
|
|
22
|
+
"x-responsesapi-include-timing-metrics",
|
|
23
|
+
];
|
|
7
24
|
|
|
8
25
|
export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } {
|
|
9
26
|
return {
|
package/src/bridge.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AdapterEvent, OcxUsage } from "./types";
|
|
2
|
+
import { classifyError, type OcxErrorPayload } from "./errors";
|
|
2
3
|
|
|
3
4
|
function uuid(): string {
|
|
4
5
|
return crypto.randomUUID().replace(/-/g, "");
|
|
@@ -8,6 +9,26 @@ function sseEvent(name: string, data: Record<string, unknown>): string {
|
|
|
8
9
|
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
|
|
13
|
+
if (!usage) return { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
|
|
14
|
+
const out: Record<string, unknown> = {
|
|
15
|
+
input_tokens: usage.inputTokens,
|
|
16
|
+
output_tokens: usage.outputTokens,
|
|
17
|
+
total_tokens: usage.inputTokens + usage.outputTokens,
|
|
18
|
+
};
|
|
19
|
+
if (usage.cachedInputTokens !== undefined) {
|
|
20
|
+
out.input_tokens_details = { cached_tokens: usage.cachedInputTokens };
|
|
21
|
+
}
|
|
22
|
+
if (usage.reasoningOutputTokens !== undefined) {
|
|
23
|
+
out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens };
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function responseError(status: number, type: string, message: string): OcxErrorPayload {
|
|
29
|
+
return classifyError(status, type, message);
|
|
30
|
+
}
|
|
31
|
+
|
|
11
32
|
interface OutputItem {
|
|
12
33
|
type: string;
|
|
13
34
|
id: string;
|
|
@@ -20,6 +41,9 @@ export function bridgeToResponsesSSE(
|
|
|
20
41
|
toolNsMap?: Map<string, { namespace: string; name: string }>,
|
|
21
42
|
freeformToolNames?: Set<string>,
|
|
22
43
|
toolSearchToolNames?: Set<string>,
|
|
44
|
+
onCancel?: () => void,
|
|
45
|
+
heartbeatMs = 2_000,
|
|
46
|
+
options?: { responseId?: string },
|
|
23
47
|
): ReadableStream<Uint8Array> {
|
|
24
48
|
// Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
|
|
25
49
|
// function with `{input:string}`, so unwrap it here when relaying back as a custom_tool_call.
|
|
@@ -32,15 +56,38 @@ export function bridgeToResponsesSSE(
|
|
|
32
56
|
try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
|
|
33
57
|
};
|
|
34
58
|
const encoder = new TextEncoder();
|
|
35
|
-
const responseId = `resp_${uuid()}`;
|
|
59
|
+
const responseId = options?.responseId ?? `resp_${uuid()}`;
|
|
36
60
|
let seq = 0;
|
|
61
|
+
// Set once the client is gone (cancel) or an enqueue throws on a torn-down controller, so we
|
|
62
|
+
// never enqueue again and never throw a second time inside start() — the RC2 double-throw that
|
|
63
|
+
// otherwise surfaced as proxy-side stream noise on every client disconnect.
|
|
64
|
+
let closed = false;
|
|
65
|
+
// RC3 keep-alive: Codex's idle timer is timeout(idle_timeout, stream.next()) over an
|
|
66
|
+
// eventsource_stream; ANY received event re-arms it, while an unknown type is ignored
|
|
67
|
+
// (responses.rs `_ => Ok(None)`). We emit a real, parser-ignored `response.heartbeat` only during
|
|
68
|
+
// upstream silence so a stalled routed provider never trips "idle timeout waiting for SSE".
|
|
69
|
+
let activity = false;
|
|
70
|
+
let beat: ReturnType<typeof setInterval> | undefined;
|
|
37
71
|
|
|
38
72
|
return new ReadableStream<Uint8Array>({
|
|
39
73
|
async start(controller) {
|
|
40
74
|
const emit = (name: string, data: Record<string, unknown>) => {
|
|
41
|
-
|
|
75
|
+
if (closed) return;
|
|
76
|
+
activity = true;
|
|
77
|
+
try {
|
|
78
|
+
controller.enqueue(encoder.encode(sseEvent(name, { type: name, sequence_number: seq++, ...data })));
|
|
79
|
+
} catch {
|
|
80
|
+
closed = true;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const emitDone = () => {
|
|
84
|
+
if (closed) return;
|
|
85
|
+
try {
|
|
86
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
87
|
+
} catch {
|
|
88
|
+
closed = true;
|
|
89
|
+
}
|
|
42
90
|
};
|
|
43
|
-
const emitDone = () => controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
44
91
|
|
|
45
92
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
46
93
|
let outputIndex = 0;
|
|
@@ -53,8 +100,18 @@ export function bridgeToResponsesSSE(
|
|
|
53
100
|
|
|
54
101
|
emit("response.created", { response: responseSnapshot("in_progress", []) });
|
|
55
102
|
|
|
103
|
+
// Re-arm Codex's idle timer during silence with a parser-ignored heartbeat (RC3). Skips a tick
|
|
104
|
+
// whenever a real event was emitted since the last tick, so it only fires on a genuine stall.
|
|
105
|
+
const heartbeatFrame = encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n');
|
|
106
|
+
beat = setInterval(() => {
|
|
107
|
+
if (closed) return;
|
|
108
|
+
if (activity) { activity = false; return; }
|
|
109
|
+
try { controller.enqueue(heartbeatFrame); } catch { closed = true; }
|
|
110
|
+
}, heartbeatMs);
|
|
111
|
+
|
|
56
112
|
let currentMsg: { itemId: string; outputIndex: number; text: string } | null = null;
|
|
57
113
|
let currentReasoning: { itemId: string; outputIndex: number; text: string } | null = null;
|
|
114
|
+
let currentRawReasoning: { itemId: string; outputIndex: number; text: string } | null = null;
|
|
58
115
|
let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; namespace?: string; freeform?: boolean; toolSearch?: boolean } | null = null;
|
|
59
116
|
|
|
60
117
|
const closeCurrentMessage = () => {
|
|
@@ -97,6 +154,18 @@ export function bridgeToResponsesSSE(
|
|
|
97
154
|
currentReasoning = null;
|
|
98
155
|
};
|
|
99
156
|
|
|
157
|
+
const closeCurrentRawReasoning = () => {
|
|
158
|
+
if (!currentRawReasoning) return;
|
|
159
|
+
const item = {
|
|
160
|
+
type: "reasoning", id: currentRawReasoning.itemId, summary: [],
|
|
161
|
+
content: [{ type: "reasoning_text", text: currentRawReasoning.text }],
|
|
162
|
+
};
|
|
163
|
+
emit("response.output_item.done", { output_index: currentRawReasoning.outputIndex, item });
|
|
164
|
+
finishedItems.push(item as OutputItem);
|
|
165
|
+
outputIndex++;
|
|
166
|
+
currentRawReasoning = null;
|
|
167
|
+
};
|
|
168
|
+
|
|
100
169
|
const closeCurrentToolCall = () => {
|
|
101
170
|
if (!currentToolCall) return;
|
|
102
171
|
// Empty input (no-arg tools like computer_use get_app_state / list_apps) must serialize as
|
|
@@ -133,11 +202,18 @@ export function bridgeToResponsesSSE(
|
|
|
133
202
|
currentToolCall = null;
|
|
134
203
|
};
|
|
135
204
|
|
|
205
|
+
// RC1: guarantee the Responses stream always ends with exactly one terminal event. Set true
|
|
206
|
+
// when a done/error/catch terminal is emitted; if the adapter generator returns without one
|
|
207
|
+
// we synthesize response.completed below, so Codex never hits the parser's
|
|
208
|
+
// "stream closed before response.completed" (responses.rs) -> ApiError::Stream.
|
|
209
|
+
let terminated = false;
|
|
210
|
+
|
|
136
211
|
try {
|
|
137
212
|
for await (const event of events) {
|
|
138
213
|
switch (event.type) {
|
|
139
214
|
case "text_delta": {
|
|
140
215
|
if (currentReasoning) closeCurrentReasoning();
|
|
216
|
+
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
141
217
|
if (currentToolCall) closeCurrentToolCall();
|
|
142
218
|
if (!currentMsg) {
|
|
143
219
|
const itemId = `msg_${uuid()}`;
|
|
@@ -161,6 +237,7 @@ export function bridgeToResponsesSSE(
|
|
|
161
237
|
}
|
|
162
238
|
case "thinking_delta": {
|
|
163
239
|
if (currentMsg) closeCurrentMessage();
|
|
240
|
+
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
164
241
|
if (currentToolCall) closeCurrentToolCall();
|
|
165
242
|
if (!currentReasoning) {
|
|
166
243
|
const itemId = `rs_${uuid()}`;
|
|
@@ -179,9 +256,27 @@ export function bridgeToResponsesSSE(
|
|
|
179
256
|
});
|
|
180
257
|
break;
|
|
181
258
|
}
|
|
259
|
+
case "reasoning_raw_delta": {
|
|
260
|
+
if (currentMsg) closeCurrentMessage();
|
|
261
|
+
if (currentReasoning) closeCurrentReasoning();
|
|
262
|
+
if (currentToolCall) closeCurrentToolCall();
|
|
263
|
+
if (!currentRawReasoning) {
|
|
264
|
+
const itemId = `rs_${uuid()}`;
|
|
265
|
+
const item = { type: "reasoning", id: itemId, summary: [] as never[], content: [] as { type: string; text: string }[] };
|
|
266
|
+
emit("response.output_item.added", { output_index: outputIndex, item });
|
|
267
|
+
currentRawReasoning = { itemId, outputIndex, text: "" };
|
|
268
|
+
}
|
|
269
|
+
currentRawReasoning.text += event.text;
|
|
270
|
+
emit("response.reasoning_text.delta", {
|
|
271
|
+
item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex,
|
|
272
|
+
content_index: 0, delta: event.text,
|
|
273
|
+
});
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
182
276
|
case "tool_call_start": {
|
|
183
277
|
if (currentMsg) closeCurrentMessage();
|
|
184
278
|
if (currentReasoning) closeCurrentReasoning();
|
|
279
|
+
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
185
280
|
if (currentToolCall) closeCurrentToolCall();
|
|
186
281
|
const itemId = `fc_${uuid()}`;
|
|
187
282
|
const mapped = toolNsMap?.get(event.name);
|
|
@@ -217,27 +312,27 @@ export function bridgeToResponsesSSE(
|
|
|
217
312
|
case "done": {
|
|
218
313
|
if (currentMsg) closeCurrentMessage();
|
|
219
314
|
if (currentReasoning) closeCurrentReasoning();
|
|
315
|
+
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
220
316
|
if (currentToolCall) closeCurrentToolCall();
|
|
221
|
-
const usage = event.usage ? {
|
|
222
|
-
input_tokens: event.usage.inputTokens,
|
|
223
|
-
output_tokens: event.usage.outputTokens,
|
|
224
|
-
total_tokens: event.usage.inputTokens + event.usage.outputTokens,
|
|
225
|
-
} : { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
|
|
226
317
|
emit("response.completed", {
|
|
227
|
-
response: { ...responseSnapshot("completed", finishedItems), usage },
|
|
318
|
+
response: { ...responseSnapshot("completed", finishedItems), usage: responsesUsage(event.usage) },
|
|
228
319
|
});
|
|
320
|
+
terminated = true;
|
|
229
321
|
break;
|
|
230
322
|
}
|
|
231
323
|
case "error": {
|
|
232
324
|
if (currentMsg) closeCurrentMessage();
|
|
233
325
|
if (currentReasoning) closeCurrentReasoning();
|
|
326
|
+
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
234
327
|
if (currentToolCall) closeCurrentToolCall();
|
|
235
328
|
emit("response.failed", {
|
|
236
329
|
response: {
|
|
237
330
|
...responseSnapshot("failed", finishedItems),
|
|
238
|
-
|
|
331
|
+
error: responseError(502, "upstream_error", event.message),
|
|
332
|
+
last_error: responseError(502, "upstream_error", event.message),
|
|
239
333
|
},
|
|
240
334
|
});
|
|
335
|
+
terminated = true;
|
|
241
336
|
break;
|
|
242
337
|
}
|
|
243
338
|
}
|
|
@@ -246,13 +341,41 @@ export function bridgeToResponsesSSE(
|
|
|
246
341
|
emit("response.failed", {
|
|
247
342
|
response: {
|
|
248
343
|
...responseSnapshot("failed", finishedItems),
|
|
249
|
-
|
|
344
|
+
error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
|
|
345
|
+
last_error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
|
|
250
346
|
},
|
|
251
347
|
});
|
|
348
|
+
terminated = true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (beat) clearInterval(beat);
|
|
352
|
+
|
|
353
|
+
if (!terminated) {
|
|
354
|
+
// The adapter generator ended without a done/error event (e.g. an upstream that closes
|
|
355
|
+
// after message_stop, or a routed provider that drops the connection cleanly). Close any
|
|
356
|
+
// open items and synthesize a clean completion so the stream is never terminal-less.
|
|
357
|
+
if (currentMsg) closeCurrentMessage();
|
|
358
|
+
if (currentReasoning) closeCurrentReasoning();
|
|
359
|
+
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
360
|
+
if (currentToolCall) closeCurrentToolCall();
|
|
361
|
+
emit("response.completed", {
|
|
362
|
+
response: { ...responseSnapshot("completed", finishedItems), usage: responsesUsage(undefined) },
|
|
363
|
+
});
|
|
252
364
|
}
|
|
253
365
|
|
|
254
366
|
emitDone();
|
|
255
|
-
|
|
367
|
+
try {
|
|
368
|
+
controller.close();
|
|
369
|
+
} catch {
|
|
370
|
+
/* already closed (e.g. client cancelled) */
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
cancel() {
|
|
374
|
+
// Client (Codex) disconnected. Stop emitting and let the caller abort the upstream fetch so a
|
|
375
|
+
// cancelled turn does not leak the upstream stream or keep draining tokens (RC2).
|
|
376
|
+
closed = true;
|
|
377
|
+
if (beat) clearInterval(beat);
|
|
378
|
+
onCancel?.();
|
|
256
379
|
},
|
|
257
380
|
});
|
|
258
381
|
}
|
|
@@ -264,13 +387,31 @@ export function buildResponseJSON(
|
|
|
264
387
|
const responseId = `resp_${uuid()}`;
|
|
265
388
|
const output: OutputItem[] = [];
|
|
266
389
|
let text = "";
|
|
390
|
+
let summaryReasoning = "";
|
|
391
|
+
let rawReasoning = "";
|
|
267
392
|
let usage: OcxUsage | undefined;
|
|
268
393
|
|
|
269
394
|
for (const e of events) {
|
|
270
395
|
if (e.type === "text_delta") text += e.text;
|
|
396
|
+
if (e.type === "thinking_delta") summaryReasoning += e.thinking;
|
|
397
|
+
if (e.type === "reasoning_raw_delta") rawReasoning += e.text;
|
|
271
398
|
if (e.type === "done") usage = e.usage;
|
|
272
399
|
}
|
|
273
400
|
|
|
401
|
+
if (rawReasoning) {
|
|
402
|
+
output.push({
|
|
403
|
+
type: "reasoning", id: `rs_${uuid()}`, summary: [],
|
|
404
|
+
content: [{ type: "reasoning_text", text: rawReasoning }],
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (summaryReasoning) {
|
|
409
|
+
output.push({
|
|
410
|
+
type: "reasoning", id: `rs_${uuid()}`,
|
|
411
|
+
summary: [{ type: "summary_text", text: summaryReasoning }],
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
274
415
|
if (text) {
|
|
275
416
|
output.push({
|
|
276
417
|
type: "message", id: `msg_${uuid()}`, role: "assistant", status: "completed",
|
|
@@ -282,15 +423,12 @@ export function buildResponseJSON(
|
|
|
282
423
|
id: responseId, object: "response",
|
|
283
424
|
created_at: Math.floor(Date.now() / 1000),
|
|
284
425
|
status: "completed", model: modelId, output,
|
|
285
|
-
usage: usage
|
|
286
|
-
input_tokens: usage.inputTokens, output_tokens: usage.outputTokens,
|
|
287
|
-
total_tokens: usage.inputTokens + usage.outputTokens,
|
|
288
|
-
} : { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
|
426
|
+
usage: responsesUsage(usage),
|
|
289
427
|
};
|
|
290
428
|
}
|
|
291
429
|
|
|
292
430
|
export function formatErrorResponse(status: number, type: string, message: string): Response {
|
|
293
|
-
return new Response(JSON.stringify({ error:
|
|
431
|
+
return new Response(JSON.stringify({ error: classifyError(status, type, message) }), {
|
|
294
432
|
status, headers: { "Content-Type": "application/json" },
|
|
295
433
|
});
|
|
296
434
|
}
|
package/src/cli.ts
CHANGED
|
File without changes
|