Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
d6c3bb0
1
Parent(s): 1dcf4e8
refactor: architecture audit fixes round 2 (P0-P2)
Browse files- Fix curl subprocess leak on client disconnect (abort signal propagation)
- Cap SSE parse buffer at 10MB to prevent memory exhaustion
- Fatal error triggers graceful shutdown instead of hard exit
- Session hash: 128-bit + JSON.stringify to prevent collisions
- Gemini SSE line endings normalized to \n\n
- Extract shared translation utils (desktop context, instructions, budgetToEffort)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- src/index.ts +3 -1
- src/proxy/codex-api.ts +4 -0
- src/routes/shared/proxy-handler.ts +2 -0
- src/session/manager.ts +2 -2
- src/translation/anthropic-to-codex.ts +3 -23
- src/translation/codex-to-gemini.ts +2 -2
- src/translation/gemini-to-codex.ts +2 -29
- src/translation/openai-to-codex.ts +2 -18
- src/translation/shared-utils.ts +47 -0
src/index.ts
CHANGED
|
@@ -150,5 +150,7 @@ async function main() {
|
|
| 150 |
|
| 151 |
main().catch((err) => {
|
| 152 |
console.error("Fatal error:", err);
|
| 153 |
-
|
|
|
|
|
|
|
| 154 |
});
|
|
|
|
| 150 |
|
| 151 |
main().catch((err) => {
|
| 152 |
console.error("Fatal error:", err);
|
| 153 |
+
// Trigger graceful shutdown instead of hard exit
|
| 154 |
+
process.kill(process.pid, "SIGTERM");
|
| 155 |
+
setTimeout(() => process.exit(1), 2000).unref();
|
| 156 |
});
|
src/proxy/codex-api.ts
CHANGED
|
@@ -356,6 +356,7 @@ export class CodexApi {
|
|
| 356 |
.pipeThrough(new TextDecoderStream())
|
| 357 |
.getReader();
|
| 358 |
|
|
|
|
| 359 |
let buffer = "";
|
| 360 |
try {
|
| 361 |
while (true) {
|
|
@@ -363,6 +364,9 @@ export class CodexApi {
|
|
| 363 |
if (done) break;
|
| 364 |
|
| 365 |
buffer += value;
|
|
|
|
|
|
|
|
|
|
| 366 |
const parts = buffer.split("\n\n");
|
| 367 |
buffer = parts.pop()!;
|
| 368 |
|
|
|
|
| 356 |
.pipeThrough(new TextDecoderStream())
|
| 357 |
.getReader();
|
| 358 |
|
| 359 |
+
const MAX_SSE_BUFFER = 10 * 1024 * 1024; // 10MB
|
| 360 |
let buffer = "";
|
| 361 |
try {
|
| 362 |
while (true) {
|
|
|
|
| 364 |
if (done) break;
|
| 365 |
|
| 366 |
buffer += value;
|
| 367 |
+
if (buffer.length > MAX_SSE_BUFFER) {
|
| 368 |
+
throw new Error(`SSE buffer exceeded ${MAX_SSE_BUFFER} bytes — aborting stream`);
|
| 369 |
+
}
|
| 370 |
const parts = buffer.split("\n\n");
|
| 371 |
buffer = parts.pop()!;
|
| 372 |
|
src/routes/shared/proxy-handler.ts
CHANGED
|
@@ -91,6 +91,7 @@ export async function handleProxyRequest(
|
|
| 91 |
|
| 92 |
// P0-2: AbortController to kill curl when client disconnects
|
| 93 |
const abortController = new AbortController();
|
|
|
|
| 94 |
|
| 95 |
try {
|
| 96 |
// 3. Retry + send to Codex
|
|
@@ -106,6 +107,7 @@ export async function handleProxyRequest(
|
|
| 106 |
c.header("Connection", "keep-alive");
|
| 107 |
|
| 108 |
return stream(c, async (s) => {
|
|
|
|
| 109 |
let sessionTaskId: string | null = null;
|
| 110 |
try {
|
| 111 |
for await (const chunk of fmt.streamTranslator(
|
|
|
|
| 91 |
|
| 92 |
// P0-2: AbortController to kill curl when client disconnects
|
| 93 |
const abortController = new AbortController();
|
| 94 |
+
c.req.raw.signal.addEventListener("abort", () => abortController.abort(), { once: true });
|
| 95 |
|
| 96 |
try {
|
| 97 |
// 3. Retry + send to Codex
|
|
|
|
| 107 |
c.header("Connection", "keep-alive");
|
| 108 |
|
| 109 |
return stream(c, async (s) => {
|
| 110 |
+
s.onAbort(() => abortController.abort());
|
| 111 |
let sessionTaskId: string | null = null;
|
| 112 |
try {
|
| 113 |
for await (const chunk of fmt.streamTranslator(
|
src/session/manager.ts
CHANGED
|
@@ -36,8 +36,8 @@ export class SessionManager {
|
|
| 36 |
hashMessages(
|
| 37 |
messages: Array<{ role: string; content: string }>,
|
| 38 |
): string {
|
| 39 |
-
const data = messages.map((m) =>
|
| 40 |
-
return createHash("sha256").update(data).digest("hex").slice(0,
|
| 41 |
}
|
| 42 |
|
| 43 |
/**
|
|
|
|
| 36 |
hashMessages(
|
| 37 |
messages: Array<{ role: string; content: string }>,
|
| 38 |
): string {
|
| 39 |
+
const data = JSON.stringify(messages.map((m) => [m.role, m.content]));
|
| 40 |
+
return createHash("sha256").update(data).digest("hex").slice(0, 32);
|
| 41 |
}
|
| 42 |
|
| 43 |
/**
|
src/translation/anthropic-to-codex.ts
CHANGED
|
@@ -2,8 +2,6 @@
|
|
| 2 |
* Translate Anthropic Messages API request → Codex Responses API request.
|
| 3 |
*/
|
| 4 |
|
| 5 |
-
import { readFileSync } from "fs";
|
| 6 |
-
import { resolve } from "path";
|
| 7 |
import type { AnthropicMessagesRequest } from "../types/anthropic.js";
|
| 8 |
import type {
|
| 9 |
CodexResponsesRequest,
|
|
@@ -11,19 +9,7 @@ import type {
|
|
| 11 |
} from "../proxy/codex-api.js";
|
| 12 |
import { resolveModelId, getModelInfo } from "../routes/models.js";
|
| 13 |
import { getConfig } from "../config.js";
|
| 14 |
-
|
| 15 |
-
const DESKTOP_CONTEXT = loadDesktopContext();
|
| 16 |
-
|
| 17 |
-
function loadDesktopContext(): string {
|
| 18 |
-
try {
|
| 19 |
-
return readFileSync(
|
| 20 |
-
resolve(process.cwd(), "config/prompts/desktop-context.md"),
|
| 21 |
-
"utf-8",
|
| 22 |
-
);
|
| 23 |
-
} catch {
|
| 24 |
-
return "";
|
| 25 |
-
}
|
| 26 |
-
}
|
| 27 |
|
| 28 |
/**
|
| 29 |
* Map Anthropic thinking budget_tokens to Codex reasoning effort.
|
|
@@ -32,11 +18,7 @@ function mapThinkingToEffort(
|
|
| 32 |
thinking: AnthropicMessagesRequest["thinking"],
|
| 33 |
): string | undefined {
|
| 34 |
if (!thinking || thinking.type === "disabled") return undefined;
|
| 35 |
-
|
| 36 |
-
if (budget < 2000) return "low";
|
| 37 |
-
if (budget < 8000) return "medium";
|
| 38 |
-
if (budget < 20000) return "high";
|
| 39 |
-
return "xhigh";
|
| 40 |
}
|
| 41 |
|
| 42 |
/**
|
|
@@ -76,9 +58,7 @@ export function translateAnthropicToCodexRequest(
|
|
| 76 |
} else {
|
| 77 |
userInstructions = "You are a helpful assistant.";
|
| 78 |
}
|
| 79 |
-
const instructions =
|
| 80 |
-
? `${DESKTOP_CONTEXT}\n\n${userInstructions}`
|
| 81 |
-
: userInstructions;
|
| 82 |
|
| 83 |
// Build input items from messages
|
| 84 |
const input: CodexInputItem[] = [];
|
|
|
|
| 2 |
* Translate Anthropic Messages API request → Codex Responses API request.
|
| 3 |
*/
|
| 4 |
|
|
|
|
|
|
|
| 5 |
import type { AnthropicMessagesRequest } from "../types/anthropic.js";
|
| 6 |
import type {
|
| 7 |
CodexResponsesRequest,
|
|
|
|
| 9 |
} from "../proxy/codex-api.js";
|
| 10 |
import { resolveModelId, getModelInfo } from "../routes/models.js";
|
| 11 |
import { getConfig } from "../config.js";
|
| 12 |
+
import { buildInstructions, budgetToEffort } from "./shared-utils.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
/**
|
| 15 |
* Map Anthropic thinking budget_tokens to Codex reasoning effort.
|
|
|
|
| 18 |
thinking: AnthropicMessagesRequest["thinking"],
|
| 19 |
): string | undefined {
|
| 20 |
if (!thinking || thinking.type === "disabled") return undefined;
|
| 21 |
+
return budgetToEffort(thinking.budget_tokens);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
/**
|
|
|
|
| 58 |
} else {
|
| 59 |
userInstructions = "You are a helpful assistant.";
|
| 60 |
}
|
| 61 |
+
const instructions = buildInstructions(userInstructions);
|
|
|
|
|
|
|
| 62 |
|
| 63 |
// Build input items from messages
|
| 64 |
const input: CodexInputItem[] = [];
|
src/translation/codex-to-gemini.ts
CHANGED
|
@@ -53,7 +53,7 @@ export async function* streamCodexToGemini(
|
|
| 53 |
],
|
| 54 |
modelVersion: model,
|
| 55 |
};
|
| 56 |
-
yield `data: ${JSON.stringify(chunk)}\
|
| 57 |
}
|
| 58 |
break;
|
| 59 |
}
|
|
@@ -84,7 +84,7 @@ export async function* streamCodexToGemini(
|
|
| 84 |
},
|
| 85 |
modelVersion: model,
|
| 86 |
};
|
| 87 |
-
yield `data: ${JSON.stringify(finalChunk)}\
|
| 88 |
break;
|
| 89 |
}
|
| 90 |
}
|
|
|
|
| 53 |
],
|
| 54 |
modelVersion: model,
|
| 55 |
};
|
| 56 |
+
yield `data: ${JSON.stringify(chunk)}\n\n`;
|
| 57 |
}
|
| 58 |
break;
|
| 59 |
}
|
|
|
|
| 84 |
},
|
| 85 |
modelVersion: model,
|
| 86 |
};
|
| 87 |
+
yield `data: ${JSON.stringify(finalChunk)}\n\n`;
|
| 88 |
break;
|
| 89 |
}
|
| 90 |
}
|
src/translation/gemini-to-codex.ts
CHANGED
|
@@ -2,8 +2,6 @@
|
|
| 2 |
* Translate Google Gemini generateContent request → Codex Responses API request.
|
| 3 |
*/
|
| 4 |
|
| 5 |
-
import { readFileSync } from "fs";
|
| 6 |
-
import { resolve } from "path";
|
| 7 |
import type {
|
| 8 |
GeminiGenerateContentRequest,
|
| 9 |
GeminiContent,
|
|
@@ -14,30 +12,7 @@ import type {
|
|
| 14 |
} from "../proxy/codex-api.js";
|
| 15 |
import { resolveModelId, getModelInfo } from "../routes/models.js";
|
| 16 |
import { getConfig } from "../config.js";
|
| 17 |
-
|
| 18 |
-
const DESKTOP_CONTEXT = loadDesktopContext();
|
| 19 |
-
|
| 20 |
-
function loadDesktopContext(): string {
|
| 21 |
-
try {
|
| 22 |
-
return readFileSync(
|
| 23 |
-
resolve(process.cwd(), "config/prompts/desktop-context.md"),
|
| 24 |
-
"utf-8",
|
| 25 |
-
);
|
| 26 |
-
} catch {
|
| 27 |
-
return "";
|
| 28 |
-
}
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
/**
|
| 32 |
-
* Map Gemini thinkingBudget to Codex reasoning effort.
|
| 33 |
-
*/
|
| 34 |
-
function budgetToEffort(budget?: number): string | undefined {
|
| 35 |
-
if (!budget || budget <= 0) return undefined;
|
| 36 |
-
if (budget < 2000) return "low";
|
| 37 |
-
if (budget < 8000) return "medium";
|
| 38 |
-
if (budget < 20000) return "high";
|
| 39 |
-
return "xhigh";
|
| 40 |
-
}
|
| 41 |
|
| 42 |
/**
|
| 43 |
* Extract text from Gemini content parts.
|
|
@@ -96,9 +71,7 @@ export function translateGeminiToCodexRequest(
|
|
| 96 |
} else {
|
| 97 |
userInstructions = "You are a helpful assistant.";
|
| 98 |
}
|
| 99 |
-
const instructions =
|
| 100 |
-
? `${DESKTOP_CONTEXT}\n\n${userInstructions}`
|
| 101 |
-
: userInstructions;
|
| 102 |
|
| 103 |
// Build input items from contents
|
| 104 |
const input: CodexInputItem[] = [];
|
|
|
|
| 2 |
* Translate Google Gemini generateContent request → Codex Responses API request.
|
| 3 |
*/
|
| 4 |
|
|
|
|
|
|
|
| 5 |
import type {
|
| 6 |
GeminiGenerateContentRequest,
|
| 7 |
GeminiContent,
|
|
|
|
| 12 |
} from "../proxy/codex-api.js";
|
| 13 |
import { resolveModelId, getModelInfo } from "../routes/models.js";
|
| 14 |
import { getConfig } from "../config.js";
|
| 15 |
+
import { buildInstructions, budgetToEffort } from "./shared-utils.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
/**
|
| 18 |
* Extract text from Gemini content parts.
|
|
|
|
| 71 |
} else {
|
| 72 |
userInstructions = "You are a helpful assistant.";
|
| 73 |
}
|
| 74 |
+
const instructions = buildInstructions(userInstructions);
|
|
|
|
|
|
|
| 75 |
|
| 76 |
// Build input items from contents
|
| 77 |
const input: CodexInputItem[] = [];
|
src/translation/openai-to-codex.ts
CHANGED
|
@@ -2,8 +2,6 @@
|
|
| 2 |
* Translate OpenAI Chat Completions request → Codex Responses API request.
|
| 3 |
*/
|
| 4 |
|
| 5 |
-
import { readFileSync } from "fs";
|
| 6 |
-
import { resolve } from "path";
|
| 7 |
import type { ChatCompletionRequest } from "../types/openai.js";
|
| 8 |
import type {
|
| 9 |
CodexResponsesRequest,
|
|
@@ -11,19 +9,7 @@ import type {
|
|
| 11 |
} from "../proxy/codex-api.js";
|
| 12 |
import { resolveModelId, getModelInfo } from "../routes/models.js";
|
| 13 |
import { getConfig } from "../config.js";
|
| 14 |
-
|
| 15 |
-
const DESKTOP_CONTEXT = loadDesktopContext();
|
| 16 |
-
|
| 17 |
-
function loadDesktopContext(): string {
|
| 18 |
-
try {
|
| 19 |
-
return readFileSync(
|
| 20 |
-
resolve(process.cwd(), "config/prompts/desktop-context.md"),
|
| 21 |
-
"utf-8",
|
| 22 |
-
);
|
| 23 |
-
} catch {
|
| 24 |
-
return "";
|
| 25 |
-
}
|
| 26 |
-
}
|
| 27 |
|
| 28 |
/**
|
| 29 |
* Convert a ChatCompletionRequest to a CodexResponsesRequest.
|
|
@@ -43,9 +29,7 @@ export function translateToCodexRequest(
|
|
| 43 |
const userInstructions =
|
| 44 |
systemMessages.map((m) => m.content).join("\n\n") ||
|
| 45 |
"You are a helpful assistant.";
|
| 46 |
-
const instructions =
|
| 47 |
-
? `${DESKTOP_CONTEXT}\n\n${userInstructions}`
|
| 48 |
-
: userInstructions;
|
| 49 |
|
| 50 |
// Build input items from non-system messages
|
| 51 |
const input: CodexInputItem[] = [];
|
|
|
|
| 2 |
* Translate OpenAI Chat Completions request → Codex Responses API request.
|
| 3 |
*/
|
| 4 |
|
|
|
|
|
|
|
| 5 |
import type { ChatCompletionRequest } from "../types/openai.js";
|
| 6 |
import type {
|
| 7 |
CodexResponsesRequest,
|
|
|
|
| 9 |
} from "../proxy/codex-api.js";
|
| 10 |
import { resolveModelId, getModelInfo } from "../routes/models.js";
|
| 11 |
import { getConfig } from "../config.js";
|
| 12 |
+
import { buildInstructions } from "./shared-utils.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
/**
|
| 15 |
* Convert a ChatCompletionRequest to a CodexResponsesRequest.
|
|
|
|
| 29 |
const userInstructions =
|
| 30 |
systemMessages.map((m) => m.content).join("\n\n") ||
|
| 31 |
"You are a helpful assistant.";
|
| 32 |
+
const instructions = buildInstructions(userInstructions);
|
|
|
|
|
|
|
| 33 |
|
| 34 |
// Build input items from non-system messages
|
| 35 |
const input: CodexInputItem[] = [];
|
src/translation/shared-utils.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Shared utilities for request translation modules.
|
| 3 |
+
*
|
| 4 |
+
* Deduplicates: desktop context loading, instruction building, budget→effort mapping.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import { readFileSync } from "fs";
|
| 8 |
+
import { resolve } from "path";
|
| 9 |
+
|
| 10 |
+
let cachedDesktopContext: string | null = null;
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* Lazily load and cache the desktop context prompt.
|
| 14 |
+
* File is maintained by apply-update.ts; cached once per process lifetime.
|
| 15 |
+
*/
|
| 16 |
+
export function getDesktopContext(): string {
|
| 17 |
+
if (cachedDesktopContext !== null) return cachedDesktopContext;
|
| 18 |
+
try {
|
| 19 |
+
cachedDesktopContext = readFileSync(
|
| 20 |
+
resolve(process.cwd(), "config/prompts/desktop-context.md"),
|
| 21 |
+
"utf-8",
|
| 22 |
+
);
|
| 23 |
+
} catch {
|
| 24 |
+
cachedDesktopContext = "";
|
| 25 |
+
}
|
| 26 |
+
return cachedDesktopContext;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Assemble final instructions from desktop context + user instructions.
|
| 31 |
+
*/
|
| 32 |
+
export function buildInstructions(userInstructions: string): string {
|
| 33 |
+
const ctx = getDesktopContext();
|
| 34 |
+
return ctx ? `${ctx}\n\n${userInstructions}` : userInstructions;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Map a token budget (e.g. Anthropic thinking.budget_tokens or Gemini thinkingBudget)
|
| 39 |
+
* to a Codex reasoning effort level.
|
| 40 |
+
*/
|
| 41 |
+
export function budgetToEffort(budget: number | undefined): string | undefined {
|
| 42 |
+
if (!budget || budget <= 0) return undefined;
|
| 43 |
+
if (budget < 2000) return "low";
|
| 44 |
+
if (budget < 8000) return "medium";
|
| 45 |
+
if (budget < 20000) return "high";
|
| 46 |
+
return "xhigh";
|
| 47 |
+
}
|