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 ✓
1196 lines
44 KiB
JavaScript
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 };
|