Files
ag-x/dist/services/translationProxy.js
admin f7378eceb0 v2.0.5: Fix E2E flow - proxy, welcome screen, provider sync
Critical fixes:
- Translation proxy now uses system Node.js (not Electron binary)
- Removed duplicate proxy start causing port conflicts
- Added port availability check before spawning proxy
- Fixed welcome:choice double resolve()
- Fixed settings.html close using deprecated remote
- Fixed translationProxy /v1 for openai-compat backends
- Proxy no longer detached/unref - properly tracked as child
- SingletonLock cleanup on startup

Verified E2E:
- Welcome screen on first run ✓
- Provider selection works ✓
- Settings save + sync ✓
- Translation proxy starts correctly ✓
- LS connects to proxy ✓
- --ag-reset works ✓
2026-05-23 12:14:04 +04:00

1196 lines
44 KiB
JavaScript

"use strict";
/**
* translationProxy.js — Self-contained Node.js translation proxy for AG X.
*
* Replaces the Python translate-proxy.py entirely.
* Supports: openai-compat, anthropic, command-code backends.
* Handles the AG X language server's Gemini-format requests and
* translates them to the appropriate backend API format.
*
* Routes:
* GET /codex/list-endpoints — list configured providers
* POST /codex/switch-endpoint — switch active provider at runtime
* GET /v1/models — list models
* GET /health — health check
* POST /v1/responses — Responses API (translated to backend)
* POST /v1internal:* — Gemini internal format (translated)
* GET /v1internal:fetchAvailableModels — model list in Gemini format
*/
const http = require("http");
const https = require("https");
const fs = require("fs");
const path = require("path");
const os = require("os");
const url = require("url");
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let BACKEND = "openai-compat";
let TARGET_URL = "http://localhost:11434/v1";
let API_KEY = "";
let OAUTH_PROVIDER = "";
let MODELS = [];
let CC_VERSION = "";
let REASONING_ENABLED = true;
let REASONING_EFFORT = "medium";
let PORT = 48080;
const CONFIG_DIR = path.join(os.homedir(), ".cache", "codex-proxy");
const ENDPOINTS_PATH = path.join(os.homedir(), ".codex", "endpoints.json");
const ACTIVE_PATH = path.join(os.homedir(), ".codex", ".active-endpoint.json");
// ---------------------------------------------------------------------------
// Init from config
// ---------------------------------------------------------------------------
function initFromConfig() {
// Try loading from active config file first
const activeConfigPath = path.join(CONFIG_DIR, "proxy-active.json");
if (fs.existsSync(activeConfigPath)) {
try {
const cfg = JSON.parse(fs.readFileSync(activeConfigPath, "utf8"));
applyConfig(cfg);
console.log("[Proxy] Loaded active config:", cfg.backend_type, cfg.target_url);
return;
} catch (e) { /* fallthrough */ }
}
// Try loading from endpoints.json
if (fs.existsSync(ENDPOINTS_PATH)) {
try {
const ep = JSON.parse(fs.readFileSync(ENDPOINTS_PATH, "utf8"));
let activeName = "";
if (fs.existsSync(ACTIVE_PATH)) {
try { activeName = JSON.parse(fs.readFileSync(ACTIVE_PATH, "utf8")).active; } catch (e) {}
}
if (!activeName) activeName = ep.default || "";
const endpoint = ep.endpoints?.find(e => e.name === activeName) || ep.endpoints?.[0];
if (endpoint) {
applyEndpoint(endpoint);
console.log("[Proxy] Loaded endpoint:", endpoint.name);
}
} catch (e) { /* fallthrough */ }
}
}
function applyConfig(cfg) {
PORT = cfg.port || 48080;
BACKEND = cfg.backend_type || "openai-compat";
TARGET_URL = cfg.target_url || "http://localhost:11434/v1";
// Ensure /v1 suffix for openai-compat backends
if (BACKEND === "openai-compat" && !TARGET_URL.endsWith("/v1")) {
TARGET_URL = TARGET_URL.replace(/\/+$/, "") + "/v1";
}
API_KEY = cfg.api_key || "";
OAUTH_PROVIDER = cfg.oauth_provider || "";
REASONING_ENABLED = cfg.reasoning_enabled !== undefined ? cfg.reasoning_enabled : true;
REASONING_EFFORT = cfg.reasoning_effort || "medium";
CC_VERSION = cfg.cc_version || "";
MODELS = cfg.models || [];
}
function applyEndpoint(endpoint) {
BACKEND = endpoint.backend_type || "openai-compat";
TARGET_URL = endpoint.base_url || "";
if (BACKEND === "openai-compat" && !TARGET_URL.endsWith("/v1")) {
TARGET_URL = TARGET_URL.replace(/\/+$/, "") + "/v1";
}
API_KEY = endpoint.api_key || "";
OAUTH_PROVIDER = endpoint.oauth_provider || "";
REASONING_ENABLED = endpoint.reasoning_enabled !== undefined ? endpoint.reasoning_enabled : true;
REASONING_EFFORT = endpoint.reasoning_effort || "medium";
CC_VERSION = endpoint.cc_version || "";
MODELS = (endpoint.models || []).map(m => ({
id: typeof m === "string" ? m : m.id,
object: "model",
created: 1700000000,
owned_by: endpoint.name || "custom"
}));
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
function jsonResponse(res, statusCode, data) {
const body = JSON.stringify(data);
res.writeHead(statusCode, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
});
res.end(body);
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", chunk => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
req.on("error", reject);
});
}
function proxyRequest(targetUrl, method, headers, bodyBuffer, res, isStream) {
return new Promise((resolve, reject) => {
const urlObj = new URL(targetUrl);
const isHttps = urlObj.protocol === "https:";
const mod = isHttps ? https : http;
const opts = {
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: method,
headers: { ...headers },
};
// Remove host header to avoid conflicts
delete opts.headers.host;
delete opts.headers["host"];
// Set correct content-length
if (bodyBuffer) {
opts.headers["content-length"] = Buffer.byteLength(bodyBuffer);
}
const upstream = mod.request(opts, (upRes) => {
if (isStream && (upRes.headers["content-type"]?.includes("text/event-stream") || upRes.headers["content-type"]?.includes("application/octet-stream"))) {
res.writeHead(upRes.statusCode, upRes.headers);
upRes.pipe(res);
upRes.on("end", resolve);
upRes.on("error", reject);
} else {
const respChunks = [];
upRes.on("data", chunk => respChunks.push(chunk));
upRes.on("end", () => {
const respBody = Buffer.concat(respChunks).toString();
if (!res.headersSent) {
res.writeHead(upRes.statusCode, upRes.headers);
}
res.end(respBody);
resolve(respBody);
});
upRes.on("error", reject);
}
});
upstream.on("error", (err) => {
console.error("[Proxy] Upstream error:", err.message);
if (!res.headersSent) {
jsonResponse(res, 502, { error: { message: `Upstream error: ${err.message}` } });
}
reject(err);
});
if (bodyBuffer) upstream.write(bodyBuffer);
upstream.end();
});
}
// ---------------------------------------------------------------------------
// Translation: Responses API → OpenAI Chat Completions
// ---------------------------------------------------------------------------
function responsesToChatCompletions(body) {
const parsed = typeof body === "string" ? JSON.parse(body) : body;
const messages = [];
// System instructions
if (parsed.instructions) {
messages.push({ role: "system", content: parsed.instructions });
}
// Input items
if (parsed.input) {
const inputItems = Array.isArray(parsed.input) ? parsed.input : [{ role: "user", content: parsed.input }];
for (const item of inputItems) {
if (typeof item === "string") {
messages.push({ role: "user", content: item });
} else if (item.type === "message") {
const content = item.content;
if (Array.isArray(content)) {
const textParts = content.filter(c => c.type === "input_text" || c.type === "text").map(c => c.text).join("\n");
if (textParts) messages.push({ role: item.role || "user", content: textParts });
} else if (typeof content === "string") {
messages.push({ role: item.role || "user", content });
}
} else if (item.type === "function_call_output" || item.type === "function_call_output") {
messages.push({ role: "tool", content: item.output || "", tool_call_id: item.call_id || item.id || "" });
} else if (item.type === "function_call") {
// Add assistant message with tool call
const lastMsg = messages[messages.length - 1];
if (lastMsg && lastMsg.role === "assistant" && lastMsg.tool_calls) {
lastMsg.tool_calls.push({
id: item.call_id || item.id || "",
type: "function",
function: { name: item.name || "", arguments: item.arguments || "{}" }
});
} else {
messages.push({
role: "assistant",
content: null,
tool_calls: [{
id: item.call_id || item.id || "",
type: "function",
function: { name: item.name || "", arguments: item.arguments || "{}" }
}]
});
}
} else if (item.role) {
messages.push({ role: item.role, content: item.content || "" });
}
}
}
// Tools
const tools = [];
if (parsed.tools) {
for (const t of parsed.tools) {
if (t.type === "function" && t.function) {
tools.push({ type: "function", function: t.function });
}
}
}
const result = {
model: parsed.model || "gpt-4o",
messages,
stream: parsed.stream || false,
};
if (tools.length > 0) result.tools = tools;
if (parsed.temperature !== undefined) result.temperature = parsed.temperature;
if (parsed.max_output_tokens !== undefined) result.max_tokens = parsed.max_output_tokens;
if (parsed.top_p !== undefined) result.top_p = parsed.top_p;
return result;
}
// ---------------------------------------------------------------------------
// Translation: Chat Completions response → Responses API
// ---------------------------------------------------------------------------
function chatToResponses(chatResp, reqModel) {
const choice = chatResp.choices?.[0];
if (!choice) {
return {
id: "resp_" + Date.now(),
object: "response",
model: reqModel || chatResp.model || "unknown",
created: Math.floor(Date.now() / 1000),
status: "failed",
output: []
};
}
const output = [];
const msg = choice.message || {};
if (msg.content) {
output.push({
type: "message",
id: "msg_" + Date.now(),
role: "assistant",
content: [{ type: "output_text", text: msg.content }]
});
}
if (msg.tool_calls) {
for (const tc of msg.tool_calls) {
output.push({
type: "function_call",
id: tc.id || "fc_" + Date.now(),
call_id: tc.id || "fc_" + Date.now(),
name: tc.function?.name || "",
arguments: tc.function?.arguments || "{}"
});
}
}
return {
id: "resp_" + Date.now(),
object: "response",
model: reqModel || chatResp.model || "unknown",
created: Math.floor(Date.now() / 1000),
status: "completed",
output,
usage: {
input_tokens: chatResp.usage?.prompt_tokens || 0,
output_tokens: chatResp.usage?.completion_tokens || 0,
total_tokens: chatResp.usage?.total_tokens || 0,
}
};
}
// ---------------------------------------------------------------------------
// Translation: Responses API → Anthropic Messages
// ---------------------------------------------------------------------------
function responsesToAnthropic(body) {
const parsed = typeof body === "string" ? JSON.parse(body) : body;
const messages = [];
let systemPrompt = "";
if (parsed.instructions) {
systemPrompt = parsed.instructions;
}
if (parsed.input) {
const inputItems = Array.isArray(parsed.input) ? parsed.input : [{ role: "user", content: parsed.input }];
for (const item of inputItems) {
if (typeof item === "string") {
messages.push({ role: "user", content: item });
} else if (item.type === "message") {
const content = item.content;
if (Array.isArray(content)) {
const textParts = content.filter(c => c.type === "input_text" || c.type === "text").map(c => c.text).join("\n");
if (textParts) messages.push({ role: item.role === "assistant" ? "assistant" : "user", content: textParts });
} else if (typeof content === "string") {
messages.push({ role: item.role === "assistant" ? "assistant" : "user", content });
}
} else if (item.type === "function_call") {
messages.push({
role: "assistant",
content: [{ type: "tool_use", id: item.call_id || item.id || "", name: item.name || "", input: JSON.parse(item.arguments || "{}") }]
});
} else if (item.type === "function_call_output") {
messages.push({
role: "user",
content: [{ type: "tool_result", tool_use_id: item.call_id || item.id || "", content: item.output || "" }]
});
} else if (item.role) {
messages.push({ role: item.role === "assistant" ? "assistant" : "user", content: item.content || "" });
}
}
}
const tools = [];
if (parsed.tools) {
for (const t of parsed.tools) {
if (t.type === "function" && t.function) {
tools.push({
name: t.function.name,
description: t.function.description || "",
input_schema: t.function.parameters || { type: "object", properties: {} }
});
}
}
}
const result = {
model: parsed.model || "claude-sonnet-4-20250514",
messages,
max_tokens: parsed.max_output_tokens || 16384,
stream: parsed.stream || false,
};
if (systemPrompt) result.system = systemPrompt;
if (tools.length > 0) result.tools = tools;
if (parsed.temperature !== undefined) result.temperature = parsed.temperature;
if (parsed.top_p !== undefined) result.top_p = parsed.top_p;
return result;
}
// ---------------------------------------------------------------------------
// Translation: Anthropic response → Responses API
// ---------------------------------------------------------------------------
function anthropicToResponses(anthroResp, reqModel) {
const output = [];
const content = anthroResp.content || [];
for (const block of content) {
if (block.type === "text") {
output.push({
type: "message",
id: "msg_" + Date.now(),
role: "assistant",
content: [{ type: "output_text", text: block.text }]
});
} else if (block.type === "tool_use") {
output.push({
type: "function_call",
id: block.id || "fc_" + Date.now(),
call_id: block.id || "fc_" + Date.now(),
name: block.name || "",
arguments: JSON.stringify(block.input || {})
});
}
}
return {
id: "resp_" + Date.now(),
object: "response",
model: reqModel || anthroResp.model || "unknown",
created: Math.floor(Date.now() / 1000),
status: anthroResp.stop_reason === "end_turn" || anthroResp.stop_reason === "stop" ? "completed" : "incomplete",
output,
usage: {
input_tokens: anthroResp.usage?.input_tokens || 0,
output_tokens: anthroResp.usage?.output_tokens || 0,
total_tokens: (anthroResp.usage?.input_tokens || 0) + (anthroResp.usage?.output_tokens || 0),
}
};
}
// ---------------------------------------------------------------------------
// Gemini internal format → Responses API
// ---------------------------------------------------------------------------
function geminiToResponses(geminiReq, stream) {
const parts = geminiReq.contents || [];
const messages = [];
for (const part of parts) {
const role = part.role === "model" ? "assistant" : "user";
const contentParts = part.parts || [];
for (const cp of contentParts) {
if (cp.text) {
messages.push({ type: "message", role, content: [{ type: "input_text", text: cp.text }] });
} else if (cp.functionCall) {
messages.push({
type: "function_call",
id: cp.functionCall.name + "_" + Date.now(),
call_id: cp.functionCall.name + "_" + Date.now(),
name: cp.functionCall.name,
arguments: JSON.stringify(cp.functionCall.args || {})
});
} else if (cp.functionResponse) {
messages.push({
type: "function_call_output",
id: cp.functionResponse.name + "_" + Date.now(),
call_id: cp.functionResponse.name + "_" + Date.now(),
output: JSON.stringify(cp.functionResponse.response?.result || cp.functionResponse.response || "")
});
}
}
}
// Convert tools
const tools = [];
if (geminiReq.tools) {
for (const t of geminiReq.tools) {
if (t.functionDeclarations) {
for (const fd of t.functionDeclarations) {
tools.push({
type: "function",
function: {
name: fd.name,
description: fd.description || "",
parameters: fd.parameters || { type: "object", properties: {} }
}
});
}
}
}
}
return {
model: (MODELS[0]?.id) || "default",
input: messages,
tools: tools.length > 0 ? tools : undefined,
stream,
};
}
// ---------------------------------------------------------------------------
// SSE streaming helpers
// ---------------------------------------------------------------------------
function parseSSE(data) {
const events = [];
let currentEvent = { event: "", data: "" };
for (const line of data.split("\n")) {
if (line.startsWith("event: ")) {
currentEvent.event = line.slice(7).trim();
} else if (line.startsWith("data: ")) {
currentEvent.data = line.slice(6);
events.push({ ...currentEvent });
currentEvent = { event: "", data: "" };
} else if (line.trim() === "" && currentEvent.data) {
events.push({ ...currentEvent });
currentEvent = { event: "", data: "" };
}
}
return events;
}
function writeSSE(res, event, data) {
if (!res.writableEnded && !res.destroyed) {
res.write(`event: ${event}\ndata: ${typeof data === "string" ? data : JSON.stringify(data)}\n\n`);
}
}
// ---------------------------------------------------------------------------
// Stream translation: OpenAI SSE → Responses API SSE
// ---------------------------------------------------------------------------
async function handleOpenAIStream(req, res, body, config) {
const chatBody = responsesToChatCompletions(body);
chatBody.stream = true;
const reqModel = chatBody.model;
const baseUrl = (TARGET_URL || "").replace(/\/+$/, "");
let targetPath = "/v1/chat/completions";
const targetUrl = baseUrl + targetPath;
const headers = { "Content-Type": "application/json" };
if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`;
const bodyStr = JSON.stringify(chatBody);
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
writeSSE(res, "response.created", {
id: "resp_" + Date.now(), object: "response", model: reqModel,
created: Math.floor(Date.now() / 1000), status: "in_progress", output: []
});
const urlObj = new URL(targetUrl);
const mod = urlObj.protocol === "https:" ? https : http;
let contentAccum = "";
let toolCallsAccum = {};
let respId = "resp_" + Date.now();
return new Promise((resolve) => {
const upstream = mod.request({
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: "POST",
headers: { ...headers, "Content-Length": Buffer.byteLength(bodyStr) },
}, (upRes) => {
let buffer = "";
upRes.on("data", (chunk) => {
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const dataStr = line.slice(6).trim();
if (dataStr === "[DONE]") {
// Flush any remaining tool calls
for (const [id, tc] of Object.entries(toolCallsAccum)) {
writeSSE(res, "response.function_call_arguments.done", {
item_id: id, arguments: tc.args
});
}
writeSSE(res, "response.completed", {
id: respId, object: "response", model: reqModel,
status: "completed",
output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: contentAccum }] }]
});
res.end();
resolve();
return;
}
try {
const chunk2 = JSON.parse(dataStr);
const delta = chunk2.choices?.[0]?.delta;
if (delta?.content) {
contentAccum += delta.content;
writeSSE(res, "response.output_text.delta", { delta: delta.content });
}
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
if (!toolCallsAccum[tc.id || "tc_0"]) {
toolCallsAccum[tc.id || "tc_0"] = { name: tc.function?.name || "", args: "" };
writeSSE(res, "response.output_item.added", {
item: { type: "function_call", id: tc.id || "tc_0", call_id: tc.id || "tc_0", name: tc.function?.name || "" }
});
}
if (tc.function?.arguments) {
toolCallsAccum[tc.id || "tc_0"].args += tc.function.arguments;
writeSSE(res, "response.function_call_arguments.delta", {
item_id: tc.id || "tc_0", delta: tc.function.arguments
});
}
}
}
} catch (e) { /* skip unparseable chunks */ }
}
}
});
upRes.on("end", () => {
if (!res.writableEnded) {
writeSSE(res, "response.completed", {
id: respId, object: "response", model: reqModel,
status: "completed", output: []
});
res.end();
}
resolve();
});
upRes.on("error", () => { if (!res.writableEnded) res.end(); resolve(); });
});
upstream.on("error", (err) => {
console.error("[Proxy] Stream upstream error:", err.message);
writeSSE(res, "response.completed", {
id: respId, status: "failed",
error: { message: err.message }
});
if (!res.writableEnded) res.end();
resolve();
});
upstream.write(bodyStr);
upstream.end();
});
}
// ---------------------------------------------------------------------------
// Stream translation: Anthropic SSE → Responses API SSE
// ---------------------------------------------------------------------------
async function handleAnthropicStream(req, res, body, config) {
const antBody = responsesToAnthropic(body);
antBody.stream = true;
const reqModel = antBody.model;
const baseUrl = (TARGET_URL || "").replace(/\/+$/, "");
const targetUrl = baseUrl + "/v1/messages";
const headers = {
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
};
if (API_KEY) headers["x-api-key"] = API_KEY;
const bodyStr = JSON.stringify(antBody);
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
const respId = "resp_" + Date.now();
writeSSE(res, "response.created", {
id: respId, object: "response", model: reqModel,
created: Math.floor(Date.now() / 1000), status: "in_progress", output: []
});
const urlObj = new URL(targetUrl);
const mod = urlObj.protocol === "https:" ? https : http;
let contentAccum = "";
let currentToolId = null;
let currentToolName = "";
let currentToolArgs = "";
return new Promise((resolve) => {
const upstream = mod.request({
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: "POST",
headers: { ...headers, "Content-Length": Buffer.byteLength(bodyStr) },
}, (upRes) => {
let buffer = "";
upRes.on("data", (chunk) => {
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const dataStr = line.slice(6).trim();
try {
const evt = JSON.parse(dataStr);
if (evt.type === "content_block_delta" && evt.delta?.type === "text_delta") {
contentAccum += evt.delta.text;
writeSSE(res, "response.output_text.delta", { delta: evt.delta.text });
} else if (evt.type === "content_block_start" && evt.content_block?.type === "tool_use") {
currentToolId = evt.content_block.id;
currentToolName = evt.content_block.name;
currentToolArgs = "";
writeSSE(res, "response.output_item.added", {
item: { type: "function_call", id: currentToolId, call_id: currentToolId, name: currentToolName }
});
} else if (evt.type === "input_json_delta" && evt.partial_json) {
currentToolArgs += evt.partial_json;
writeSSE(res, "response.function_call_arguments.delta", {
item_id: currentToolId, delta: evt.partial_json
});
} else if (evt.type === "message_stop") {
if (currentToolId) {
writeSSE(res, "response.function_call_arguments.done", {
item_id: currentToolId, arguments: currentToolArgs
});
}
writeSSE(res, "response.completed", {
id: respId, object: "response", model: reqModel,
status: "completed",
output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: contentAccum }] }]
});
res.end();
resolve();
return;
}
} catch (e) { /* skip */ }
}
}
});
upRes.on("end", () => {
if (!res.writableEnded) {
writeSSE(res, "response.completed", { id: respId, status: "completed", output: [] });
res.end();
}
resolve();
});
upRes.on("error", () => { if (!res.writableEnded) res.end(); resolve(); });
});
upstream.on("error", (err) => {
writeSSE(res, "response.completed", { id: respId, status: "failed", error: { message: err.message } });
if (!res.writableEnded) res.end();
resolve();
});
upstream.write(bodyStr);
upstream.end();
});
}
// ---------------------------------------------------------------------------
// Command-Code backend (passthrough with headers)
// ---------------------------------------------------------------------------
async function handleCommandCode(req, res, bodyStr) {
const parsed = typeof bodyStr === "string" ? JSON.parse(bodyStr) : bodyStr;
const isStream = parsed.stream || false;
const baseUrl = (TARGET_URL || "").replace(/\/+$/, "");
const targetUrl = baseUrl + "/alpha/generate";
const headers = {
"Content-Type": "application/json",
"x-command-code-version": CC_VERSION || "0.26.8",
};
if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`;
if (isStream) {
// SSE passthrough with Responses API wrapping
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
const respId = "resp_" + Date.now();
const reqModel = parsed.model || MODELS[0]?.id || "default";
writeSSE(res, "response.created", {
id: respId, object: "response", model: reqModel,
created: Math.floor(Date.now() / 1000), status: "in_progress", output: []
});
const bodyData = JSON.stringify(parsed);
const urlObj = new URL(targetUrl);
const mod = urlObj.protocol === "https:" ? https : http;
let contentAccum = "";
return new Promise((resolve) => {
const upstream = mod.request({
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: "POST",
headers: { ...headers, "Content-Length": Buffer.byteLength(bodyData) },
}, (upRes) => {
let buffer = "";
upRes.on("data", (chunk) => {
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const dataStr = line.slice(6).trim();
if (dataStr === "[DONE]") {
writeSSE(res, "response.completed", {
id: respId, object: "response", model: reqModel,
status: "completed",
output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: contentAccum }] }]
});
res.end();
resolve();
return;
}
try {
const chunk2 = JSON.parse(dataStr);
// CC streaming format varies, try common patterns
const delta = chunk2.choices?.[0]?.delta?.content || chunk2.text || chunk2.delta || chunk2.content || "";
if (delta) {
contentAccum += delta;
writeSSE(res, "response.output_text.delta", { delta });
}
} catch (e) { /* skip */ }
}
}
});
upRes.on("end", () => {
if (!res.writableEnded) {
writeSSE(res, "response.completed", { id: respId, status: "completed", output: [] });
res.end();
}
resolve();
});
upRes.on("error", () => { if (!res.writableEnded) res.end(); resolve(); });
});
upstream.on("error", (err) => {
writeSSE(res, "response.completed", { id: respId, status: "failed", error: { message: err.message } });
if (!res.writableEnded) res.end();
resolve();
});
upstream.write(bodyData);
upstream.end();
});
} else {
// Non-stream: forward and wrap response
const bodyData = JSON.stringify(parsed);
const urlObj = new URL(targetUrl);
const mod = urlObj.protocol === "https:" ? https : http;
return new Promise((resolve) => {
const upstream = mod.request({
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: "POST",
headers: { ...headers, "Content-Length": Buffer.byteLength(bodyData) },
}, (upRes) => {
const chunks = [];
upRes.on("data", chunk => chunks.push(chunk));
upRes.on("end", () => {
const respBody = Buffer.concat(chunks).toString();
try {
const ccResp = JSON.parse(respBody);
// Wrap in Responses API format
const text = ccResp.choices?.[0]?.message?.content || ccResp.content || ccResp.text || ccResp.output || respBody;
const result = {
id: "resp_" + Date.now(),
object: "response",
model: parsed.model || "unknown",
created: Math.floor(Date.now() / 1000),
status: "completed",
output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text }] }],
};
jsonResponse(res, upRes.statusCode, result);
} catch (e) {
jsonResponse(res, upRes.statusCode, {
id: "resp_" + Date.now(), status: "completed",
output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: respBody }] }]
});
}
resolve();
});
upRes.on("error", () => { resolve(); });
});
upstream.on("error", (err) => {
jsonResponse(res, 502, { error: { message: err.message } });
resolve();
});
upstream.write(bodyData);
upstream.end();
});
}
}
// ---------------------------------------------------------------------------
// Gemini internal → backend translation
// ---------------------------------------------------------------------------
async function handleGeminiInternal(req, res) {
const isStream = req.url?.includes("streamGenerateContent") || false;
const bodyStr = await readBody(req);
let geminiReq;
try { geminiReq = JSON.parse(bodyStr); } catch (e) { return jsonResponse(res, 400, { error: { message: "Invalid JSON" } }); }
const responsesBody = geminiToResponses(geminiReq, isStream);
if (BACKEND === "openai-compat") {
if (isStream) {
return await handleOpenAIStream(req, res, JSON.stringify(responsesBody));
}
const chatBody = responsesToChatCompletions(responsesBody);
const baseUrl = (TARGET_URL || "").replace(/\/+$/, "");
const targetUrl = baseUrl + "/chat/completions";
const headers = { "Content-Type": "application/json" };
if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`;
const resp = await proxyRequest(targetUrl, "POST", headers, JSON.stringify(chatBody), res, false);
try {
const chatResp = JSON.parse(resp);
// Convert back to Gemini format
const text = chatResp.choices?.[0]?.message?.content || "";
const geminiResp = {
candidates: [{ content: { role: "model", parts: [{ text }] }, finishReason: "STOP", index: 0 }]
};
jsonResponse(res, 200, geminiResp);
} catch (e) { /* already sent */ }
} else if (BACKEND === "anthropic") {
if (isStream) {
return await handleAnthropicStream(req, res, JSON.stringify(responsesBody));
}
const antBody = responsesToAnthropic(responsesBody);
const baseUrl = (TARGET_URL || "").replace(/\/+$/, "");
const targetUrl = baseUrl + "/v1/messages";
const headers = { "Content-Type": "application/json", "anthropic-version": "2023-06-01" };
if (API_KEY) headers["x-api-key"] = API_KEY;
const resp = await proxyRequest(targetUrl, "POST", headers, JSON.stringify(antBody), res, false);
try {
const antResp = JSON.parse(resp);
const text = antResp.content?.find(c => c.type === "text")?.text || "";
const geminiResp = {
candidates: [{ content: { role: "model", parts: [{ text }] }, finishReason: "STOP", index: 0 }]
};
jsonResponse(res, 200, geminiResp);
} catch (e) { /* already sent */ }
} else if (BACKEND === "command-code") {
if (isStream) {
// Need to handle Gemini SSE format
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
// Simplified: translate to CC, stream back as Gemini chunks
const bodyData = JSON.stringify(responsesBody);
const baseUrl = (TARGET_URL || "").replace(/\/+$/, "");
const targetUrl = baseUrl + "/alpha/generate";
const headers = { "Content-Type": "application/json", "x-command-code-version": CC_VERSION || "0.26.8" };
if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`;
const urlObj = new URL(targetUrl);
const mod = urlObj.protocol === "https:" ? https : http;
return new Promise((resolve) => {
const upstream = mod.request({
hostname: urlObj.hostname, port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80),
path: urlObj.pathname + urlObj.search, method: "POST",
headers: { ...headers, "Content-Length": Buffer.byteLength(bodyData) },
}, (upRes) => {
let buffer = "";
upRes.on("data", (chunk) => {
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const dataStr = line.slice(6).trim();
if (dataStr === "[DONE]") { res.end(); resolve(); return; }
try {
const c = JSON.parse(dataStr);
const delta = c.choices?.[0]?.delta?.content || c.text || c.delta || c.content || "";
if (delta) {
const geminiChunk = { candidates: [{ content: { role: "model", parts: [{ text: delta }] }, index: 0 }] };
res.write(`data: ${JSON.stringify(geminiChunk)}\n\n`);
}
} catch (e) { /* skip */ }
}
}
});
upRes.on("end", () => { if (!res.writableEnded) res.end(); resolve(); });
upRes.on("error", () => { if (!res.writableEnded) res.end(); resolve(); });
});
upstream.on("error", () => { if (!res.writableEnded) res.end(); resolve(); });
upstream.write(bodyData);
upstream.end();
});
}
// Non-stream CC
const bodyData = JSON.stringify(responsesBody);
const baseUrl = (TARGET_URL || "").replace(/\/+$/, "");
const targetUrl = baseUrl + "/alpha/generate";
const headers = { "Content-Type": "application/json", "x-command-code-version": CC_VERSION || "0.26.8" };
if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`;
const resp = await proxyRequest(targetUrl, "POST", headers, bodyData, res, false);
try {
const ccResp = JSON.parse(resp);
const text = ccResp.choices?.[0]?.message?.content || ccResp.content || ccResp.text || "";
const geminiResp = {
candidates: [{ content: { role: "model", parts: [{ text }] }, finishReason: "STOP", index: 0 }]
};
jsonResponse(res, 200, geminiResp);
} catch (e) { /* already sent */ }
} else {
jsonResponse(res, 400, { error: { message: `Unsupported backend: ${BACKEND}` } });
}
}
// ---------------------------------------------------------------------------
// Main request handler
// ---------------------------------------------------------------------------
async function handleRequest(req, res) {
const parsedUrl = url.parse(req.url || "/");
// CORS
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method === "OPTIONS") {
res.writeHead(204);
return res.end();
}
try {
// --- Management routes ---
if (parsedUrl.pathname === "/codex/list-endpoints" && req.method === "GET") {
let endpointsData = { default: "", endpoints: [] };
if (fs.existsSync(ENDPOINTS_PATH)) {
try { endpointsData = JSON.parse(fs.readFileSync(ENDPOINTS_PATH, "utf8")); } catch (e) {}
}
let activeName = "";
if (fs.existsSync(ACTIVE_PATH)) {
try { activeName = JSON.parse(fs.readFileSync(ACTIVE_PATH, "utf8")).active; } catch (e) {}
}
if (!activeName) activeName = endpointsData.default || "";
return jsonResponse(res, 200, {
endpoints: endpointsData.endpoints || [],
active: activeName
});
}
if (parsedUrl.pathname === "/codex/switch-endpoint" && req.method === "POST") {
const body = await readBody(req);
let parsed;
try { parsed = JSON.parse(body); } catch (e) { return jsonResponse(res, 400, { error: { message: "Invalid JSON" } }); }
const endpointName = parsed.name;
if (!fs.existsSync(ENDPOINTS_PATH)) {
return jsonResponse(res, 404, { error: { message: "endpoints.json not found" } });
}
let endpointsData;
try { endpointsData = JSON.parse(fs.readFileSync(ENDPOINTS_PATH, "utf8")); } catch (e) {
return jsonResponse(res, 500, { error: { message: "Failed to read endpoints" } });
}
const endpoint = endpointsData.endpoints?.find(e => e.name === endpointName);
if (!endpoint) {
return jsonResponse(res, 404, { error: { message: `Endpoint '${endpointName}' not found` } });
}
applyEndpoint(endpoint);
// Save active endpoint
try {
fs.mkdirSync(path.dirname(ACTIVE_PATH), { recursive: true });
fs.writeFileSync(ACTIVE_PATH, JSON.stringify({ active: endpointName }));
} catch (e) {}
// Save active config
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, "proxy-active.json"), JSON.stringify({
port: PORT, backend_type: BACKEND, target_url: TARGET_URL,
api_key: API_KEY, cc_version: CC_VERSION, oauth_provider: OAUTH_PROVIDER,
reasoning_enabled: REASONING_ENABLED, reasoning_effort: REASONING_EFFORT,
models: MODELS
}, null, 2));
} catch (e) {}
console.log(`[Proxy] Switched to: ${endpointName} (${BACKEND})`);
return jsonResponse(res, 200, { success: true, active: endpointName });
}
if (parsedUrl.pathname === "/v1/models" && req.method === "GET") {
return jsonResponse(res, 200, { object: "list", data: MODELS });
}
if (parsedUrl.pathname === "/health" && req.method === "GET") {
return jsonResponse(res, 200, {
ok: true, backend: BACKEND, target_url: TARGET_URL,
models: MODELS.map(m => m.id || m)
});
}
// --- Gemini internal routes ---
if (parsedUrl.pathname?.startsWith("/v1internal:")) {
if (parsedUrl.pathname.startsWith("/v1internal:fetchAvailableModels")) {
const modelsList = MODELS.map(m => ({
name: `models/${typeof m === "string" ? m : m.id}`,
version: "1.0",
displayName: typeof m === "string" ? m : m.id,
description: `Model via AG X Proxy`,
supportedGenerationMethods: ["generateContent", "streamGenerateContent"]
}));
return jsonResponse(res, 200, { models: modelsList });
}
return await handleGeminiInternal(req, res);
}
// --- Responses API route ---
if (parsedUrl.pathname === "/v1/responses" || parsedUrl.pathname === "/responses") {
const bodyStr = await readBody(req);
let parsed;
try { parsed = typeof bodyStr === "string" ? JSON.parse(bodyStr) : bodyStr; } catch (e) {
return jsonResponse(res, 400, { error: { message: "Invalid JSON" } });
}
const isStream = parsed.stream || false;
if (BACKEND === "openai-compat") {
if (isStream) {
return await handleOpenAIStream(req, res, bodyStr);
}
const chatBody = responsesToChatCompletions(bodyStr);
const baseUrl = (TARGET_URL || "").replace(/\/+$/, "");
const targetUrl = baseUrl + "/chat/completions";
const headers = { "Content-Type": "application/json" };
if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`;
const resp = await proxyRequest(targetUrl, "POST", headers, JSON.stringify(chatBody), res, false);
try {
const chatResp = JSON.parse(resp);
jsonResponse(res, 200, chatToResponses(chatResp, chatBody.model));
} catch (e) { /* response already sent */ }
} else if (BACKEND === "anthropic") {
if (isStream) {
return await handleAnthropicStream(req, res, bodyStr);
}
const antBody = responsesToAnthropic(bodyStr);
const baseUrl = (TARGET_URL || "").replace(/\/+$/, "");
const targetUrl = baseUrl + "/v1/messages";
const headers = { "Content-Type": "application/json", "anthropic-version": "2023-06-01" };
if (API_KEY) headers["x-api-key"] = API_KEY;
const resp = await proxyRequest(targetUrl, "POST", headers, JSON.stringify(antBody), res, false);
try {
const antResp = JSON.parse(resp);
jsonResponse(res, 200, anthropicToResponses(antResp, antBody.model));
} catch (e) { /* response already sent */ }
} else if (BACKEND === "command-code") {
return await handleCommandCode(req, res, bodyStr);
} else {
jsonResponse(res, 400, { error: { message: `Unsupported backend: ${BACKEND}` } });
}
return;
}
// 404
jsonResponse(res, 404, { error: { message: "Not found" } });
} catch (err) {
console.error("[Proxy] Unhandled error:", err);
if (!res.headersSent) {
jsonResponse(res, 500, { error: { message: err.message } });
}
}
}
// ---------------------------------------------------------------------------
// Start server
// ---------------------------------------------------------------------------
function start() {
initFromConfig();
const server = http.createServer(handleRequest);
server.listen(PORT, "127.0.0.1", () => {
console.log(`[AG X Proxy] Listening on http://127.0.0.1:${PORT}`);
console.log(`[AG X Proxy] Backend: ${BACKEND}, Target: ${TARGET_URL}`);
console.log(`[AG X Proxy] Models: ${MODELS.map(m => typeof m === "string" ? m : m.id).join(", ")}`);
});
server.on("error", (err) => {
console.error("[AG X Proxy] Server error:", err.message);
process.exit(1);
});
process.on("SIGTERM", () => { server.close(); process.exit(0); });
process.on("SIGINT", () => { server.close(); process.exit(0); });
}
// Allow running standalone
if (require.main === module) {
start();
}
module.exports = { start, handleRequest, applyEndpoint };