mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
Remote HTTP(S) image URLs are fetched and inlined as base64 data URIs in a new prefetchImages() step run before super.execute(), so the body sent to Codex contains resolved image bytes instead of URLs the backend cannot access. Scope is limited to the Codex executor — base executor and other providers are untouched. Co-authored-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com> Made-with: Cursor
199 lines
7.3 KiB
JavaScript
199 lines
7.3 KiB
JavaScript
import { createHash } from "crypto";
|
|
import { BaseExecutor } from "./base.js";
|
|
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js";
|
|
import { PROVIDERS } from "../config/providers.js";
|
|
import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js";
|
|
import { fetchImageAsBase64 } from "../translator/helpers/imageHelper.js";
|
|
import { getConsistentMachineId } from "../../src/shared/utils/machineId.js";
|
|
|
|
// In-memory map: hash(machineId + first assistant content) → { sessionId, lastUsed }
|
|
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
const assistantSessionMap = new Map();
|
|
|
|
// Cache machine ID at module level (resolved once)
|
|
let cachedMachineId = null;
|
|
getConsistentMachineId().then(id => { cachedMachineId = id; });
|
|
|
|
function hashContent(text) {
|
|
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
function generateSessionId() {
|
|
return `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
|
}
|
|
|
|
// Extract text content from an input item
|
|
function extractItemText(item) {
|
|
if (!item) return "";
|
|
if (typeof item.content === "string") return item.content;
|
|
if (Array.isArray(item.content)) {
|
|
return item.content.map(c => c.text || c.output || "").filter(Boolean).join("");
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// Resolve session_id from first assistant message + machineId to avoid cross-user collision
|
|
function resolveConversationSessionId(input, machineId) {
|
|
const machineSessionId = machineId ? `sess_${hashContent(machineId)}` : generateSessionId();
|
|
if (!Array.isArray(input) || input.length === 0) return machineSessionId;
|
|
|
|
// Find first assistant message that has actual text content
|
|
let text = "";
|
|
for (const item of input) {
|
|
if (item.role === "assistant") {
|
|
text = extractItemText(item);
|
|
if (text) break;
|
|
}
|
|
}
|
|
if (!text) return machineSessionId;
|
|
|
|
const hash = hashContent((machineId || "") + text);
|
|
const entry = assistantSessionMap.get(hash);
|
|
if (entry) {
|
|
entry.lastUsed = Date.now();
|
|
return entry.sessionId;
|
|
}
|
|
|
|
|
|
const sessionId = generateSessionId();
|
|
assistantSessionMap.set(hash, { sessionId, lastUsed: Date.now() });
|
|
return sessionId;
|
|
}
|
|
|
|
// Cleanup expired entries periodically
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of assistantSessionMap) {
|
|
if (now - entry.lastUsed > SESSION_TTL_MS) assistantSessionMap.delete(key);
|
|
}
|
|
}, 10 * 60 * 1000);
|
|
|
|
/**
|
|
* Codex Executor - handles OpenAI Codex API (Responses API format)
|
|
* Automatically injects default instructions if missing
|
|
*/
|
|
export class CodexExecutor extends BaseExecutor {
|
|
constructor() {
|
|
super("codex", PROVIDERS.codex);
|
|
this._currentSessionId = null;
|
|
}
|
|
|
|
/**
|
|
* Override headers to add session_id per conversation
|
|
* transformRequest runs BEFORE buildHeaders, sets this._currentSessionId
|
|
*/
|
|
buildHeaders(credentials, stream = true) {
|
|
const headers = super.buildHeaders(credentials, stream);
|
|
headers["session_id"] = this._currentSessionId || credentials?.connectionId || "default";
|
|
return headers;
|
|
}
|
|
|
|
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
|
const base = super.buildUrl(model, stream, urlIndex, credentials);
|
|
return this._isCompact ? `${base}/compact` : base;
|
|
}
|
|
|
|
/**
|
|
* Prefetch remote image URLs and inline them as base64 data URIs.
|
|
* Runs before execute() because Codex backend cannot fetch remote images.
|
|
* Mutates body.input in place.
|
|
*/
|
|
async prefetchImages(body) {
|
|
if (!Array.isArray(body?.input)) return;
|
|
for (const item of body.input) {
|
|
if (!Array.isArray(item.content)) continue;
|
|
const pending = item.content.map(async (c) => {
|
|
if (c.type !== "image_url") return c;
|
|
const url = typeof c.image_url === "string" ? c.image_url : c.image_url?.url;
|
|
const detail = c.image_url?.detail || "auto";
|
|
if (!url) return c;
|
|
if (url.startsWith("data:")) return { type: "input_image", image_url: url, detail };
|
|
const fetched = await fetchImageAsBase64(url, { timeoutMs: 15000 });
|
|
return { type: "input_image", image_url: fetched?.url || url, detail };
|
|
});
|
|
item.content = await Promise.all(pending);
|
|
}
|
|
}
|
|
|
|
async execute(args) {
|
|
// Fetch remote images before the synchronous transform/execute pipeline
|
|
await this.prefetchImages(args.body);
|
|
return super.execute(args);
|
|
}
|
|
|
|
/**
|
|
* Transform request before sending - inject default instructions if missing.
|
|
* Image fetching is handled separately in prefetchImages() so this stays sync.
|
|
*/
|
|
transformRequest(model, body, stream, credentials) {
|
|
this._isCompact = !!body._compact;
|
|
delete body._compact;
|
|
// Resolve conversation-stable session_id from input history + machineId
|
|
this._currentSessionId = resolveConversationSessionId(body.input, cachedMachineId);
|
|
// Convert string input to array format (Codex API requires input as array)
|
|
const normalized = normalizeResponsesInput(body.input);
|
|
if (normalized) body.input = normalized;
|
|
|
|
// Ensure input is present and non-empty (Codex API rejects empty input)
|
|
if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) {
|
|
body.input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }];
|
|
}
|
|
|
|
// Ensure streaming is enabled (Codex API requires it)
|
|
body.stream = true;
|
|
|
|
// If no instructions provided, inject default Codex instructions
|
|
if (!body.instructions || body.instructions.trim() === "") {
|
|
body.instructions = CODEX_DEFAULT_INSTRUCTIONS;
|
|
}
|
|
|
|
// Ensure store is false (Codex requirement)
|
|
body.store = false;
|
|
|
|
// Extract thinking level from model name suffix
|
|
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
|
|
const effortLevels = ['none', 'low', 'medium', 'high', 'xhigh'];
|
|
let modelEffort = null;
|
|
for (const level of effortLevels) {
|
|
if (model.endsWith(`-${level}`)) {
|
|
modelEffort = level;
|
|
// Strip suffix from model name for actual API call
|
|
body.model = body.model.replace(`-${level}`, '');
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
|
|
if (!body.reasoning) {
|
|
const effort = body.reasoning_effort || modelEffort || 'low';
|
|
body.reasoning = { effort, summary: "auto" };
|
|
} else if (!body.reasoning.summary) {
|
|
body.reasoning.summary = "auto";
|
|
}
|
|
delete body.reasoning_effort;
|
|
|
|
// Include reasoning encrypted content (required by Codex backend for reasoning models)
|
|
if (body.reasoning && body.reasoning.effort && body.reasoning.effort !== 'none') {
|
|
body.include = ["reasoning.encrypted_content"];
|
|
}
|
|
|
|
// Remove unsupported parameters for Codex API
|
|
delete body.temperature;
|
|
delete body.top_p;
|
|
delete body.frequency_penalty;
|
|
delete body.presence_penalty;
|
|
delete body.logprobs;
|
|
delete body.top_logprobs;
|
|
delete body.n;
|
|
delete body.seed;
|
|
delete body.max_tokens;
|
|
delete body.user; // Cursor sends this but Codex doesn't support it
|
|
delete body.prompt_cache_retention; // Cursor sends this but Codex doesn't support it
|
|
delete body.metadata; // Cursor sends this but Codex doesn't support it
|
|
delete body.stream_options; // Cursor sends this but Codex doesn't support it
|
|
delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it
|
|
|
|
return body;
|
|
}
|
|
}
|