|
|
| "use strict";
|
|
|
| const https = require("https");
|
| const http = require("http");
|
| const crypto = require("crypto");
|
|
|
|
|
|
|
|
|
| const CONFIG = require("./config.json");
|
| const BASE_HOST = "ima.qq.com";
|
| const BASE_URL = "https://ima.qq.com";
|
|
|
| let COOKIE = (() => {
|
| const envCookie = process.env.IMA_COOKIE;
|
| if (envCookie) return envCookie;
|
| const cfgCookie = CONFIG.auth.cookie;
|
|
|
| if (cfgCookie && /^[ -]+$/.test(cfgCookie)) return cfgCookie;
|
| return '';
|
| })();
|
| let IMA_TOKEN = "";
|
| let BKN = "";
|
|
|
|
|
|
|
|
|
| const IMA_HEADERS = {
|
| "from_browser_ima": "1",
|
| "x-ima-cookie": COOKIE,
|
| "x-ima-bkn": BKN,
|
| referer: BASE_URL,
|
| origin: BASE_URL,
|
| "User-Agent": "okhttp/4.12.0",
|
| "Content-Type": "application/json; charset=utf-8",
|
| "Accept-Encoding": "gzip",
|
| };
|
|
|
| function rebuildCookieState() {
|
| const m = COOKIE.match(/IMA-TOKEN=([^;]+)/);
|
| IMA_TOKEN = m ? m[1] : "";
|
| let h = 5381;
|
| for (let i = 0; i < IMA_TOKEN.length; i++) h += (h << 5) + IMA_TOKEN.charCodeAt(i);
|
| BKN = String(h & 0x7fffffff);
|
| IMA_HEADERS["x-ima-cookie"] = COOKIE;
|
| IMA_HEADERS["x-ima-bkn"] = BKN;
|
| }
|
| rebuildCookieState();
|
|
|
|
|
| const REFRESH_TOKEN = process.env.IMA_REFRESH_TOKEN || "";
|
| const REGISTRATION_ID = process.env.IMA_REGISTRATION_ID || "";
|
| let tokenExpiresAt = 0;
|
| let refreshTimer = null;
|
|
|
| async function refreshImaToken() {
|
| if (!REFRESH_TOKEN || !REGISTRATION_ID) return false;
|
| const uidMatch = COOKIE.match(/IMA-UID=([^;]+)/);
|
| if (!uidMatch) return false;
|
| const uid = uidMatch[1];
|
| try {
|
| const result = await imaPost("/auth_login/refresh", {
|
| user_id: uid,
|
| refresh_token: REFRESH_TOKEN,
|
| token_type: 14,
|
| registration_id: REGISTRATION_ID,
|
| }, {}, 15000);
|
| if (result && result.data && result.data.cookie) {
|
| COOKIE = result.data.cookie;
|
| rebuildCookieState();
|
| if (result.data.expires_in) tokenExpiresAt = Date.now() + result.data.expires_in * 1000;
|
| console.log(`[REFRESH] Token refreshed, valid ${result.data.expires_in || "?"}s, BKN=${BKN}`);
|
| return true;
|
| }
|
| } catch (e) {
|
| console.log(`[REFRESH] Failed: ${e.message}`);
|
| }
|
| return false;
|
| }
|
|
|
| function scheduleAutoRefresh() {
|
| if (!REFRESH_TOKEN || !REGISTRATION_ID) return;
|
| const interval = 600000;
|
| refreshTimer = setInterval(async () => {
|
| if (Date.now() > tokenExpiresAt - 300000) await refreshImaToken();
|
| }, interval);
|
| }
|
|
|
|
|
| const API_KEYS = new Set();
|
| if (process.env.IMA_API_KEYS) {
|
| process.env.IMA_API_KEYS.split(",").forEach(k => API_KEYS.add(k.trim()));
|
| } else {
|
| (CONFIG.api_keys || []).forEach(k => API_KEYS.add(k));
|
| }
|
|
|
| function maskKey(k) {
|
| if (!k) return "(empty)";
|
| if (k.length <= 12) return k;
|
| return k.slice(0, 6) + "..." + k.slice(-4);
|
| }
|
|
|
|
|
| function imaPost(path, body, extraH = {}, timeout = 30000) {
|
| return new Promise((resolve, reject) => {
|
| const payload = JSON.stringify(body);
|
| const req = https.request({
|
| hostname: BASE_HOST, port: 443, path, method: "POST",
|
| headers: { ...IMA_HEADERS, ...extraH, "Content-Length": Buffer.byteLength(payload) },
|
| timeout,
|
| }, (res) => {
|
|
|
| const zlib = require("zlib");
|
| const encoding = res.headers["content-encoding"] || "";
|
| let stream = res;
|
| if (encoding === "gzip") stream = res.pipe(zlib.createGunzip());
|
| else if (encoding === "deflate") stream = res.pipe(zlib.createInflate());
|
| const chunks = [];
|
| stream.on("data", (c) => chunks.push(c));
|
| stream.on("end", () => {
|
| const raw = Buffer.concat(chunks).toString("utf-8");
|
| try { resolve({ status: res.statusCode, data: JSON.parse(raw) }); }
|
| catch { resolve({ status: res.statusCode, data: raw }); }
|
| });
|
| stream.on("error", reject);
|
| });
|
| req.on("error", reject);
|
| req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
|
| req.write(payload); req.end();
|
| });
|
| }
|
|
|
| function imaSse(path, body) {
|
| return new Promise((resolve, reject) => {
|
| const payload = JSON.stringify(body);
|
| const req = https.request({
|
| hostname: BASE_HOST, port: 443, path, method: "POST",
|
| headers: { ...IMA_HEADERS, Accept: "text/event-stream", "Content-Length": Buffer.byteLength(payload) },
|
| timeout: 180000,
|
| }, (res) => {
|
| if (res.statusCode !== 200) {
|
| let e = ""; res.on("data", c => e += c);
|
| res.on("end", () => reject(new Error(`HTTP ${res.statusCode}: ${e}`)));
|
| return;
|
| }
|
| resolve(parseSSE(res));
|
| });
|
| req.on("error", reject);
|
| req.on("timeout", () => { req.destroy(); reject(new Error("SSE timeout")); });
|
| req.write(payload); req.end();
|
| });
|
| }
|
|
|
| function parseSSEBlock(part) {
|
| let event = "";
|
| const dataLines = [];
|
| for (const line of part.split("\n")) {
|
| if (line.startsWith("event:")) event = line.slice(6).trim();
|
| else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
|
| else if (line.startsWith("data")) dataLines.push("");
|
| }
|
| return { event, data: dataLines.join("\n") };
|
| }
|
|
|
| async function* parseSSE(readable) {
|
| let buf = "";
|
| for await (const chunk of readable) {
|
| buf += chunk.toString("utf-8");
|
| const parts = buf.split(/\r?\n\r?\n/); buf = parts.pop() || "";
|
| for (const part of parts) {
|
| if (!part.trim()) continue;
|
| const evt = parseSSEBlock(part);
|
| if (evt.event || evt.data) yield evt;
|
| }
|
| }
|
| if (buf.trim()) {
|
| const evt = parseSSEBlock(buf);
|
| if (evt.event || evt.data) yield evt;
|
| }
|
| }
|
|
|
| const CONTROL_EVENTS = new Set(["COMPLETED", "CLOSE", "INNER_EXCEPTION", "ERROR", "FAILED"]);
|
| const DEBUG_SSE = process.env.IMA_DEBUG === "1";
|
|
|
| function extractEventText(d) {
|
| if (d == null) return "";
|
| if (typeof d === "string") return d;
|
| if (typeof d !== "object") return String(d);
|
| for (const k of ["Text", "text", "Content", "content", "Delta", "delta", "Msg", "msg", "reply", "Reply", "answer", "Answer"]) {
|
| const v = d[k];
|
| if (typeof v === "string" && v) return v;
|
| }
|
| return "";
|
| }
|
|
|
| function eventText(evt) {
|
| const raw = evt && evt.data;
|
| if (!raw) return "";
|
| let d;
|
| try { d = JSON.parse(raw); }
|
| catch { try { d = tryRepairJson(raw); } catch { d = null; } }
|
| if (d == null) return "";
|
| return extractEventText(d);
|
| }
|
|
|
|
|
|
|
|
|
| async function imaInitSession(question) {
|
| const { data } = await imaPost("/cgi-bin/session_logic/init_session", {
|
| env_info: { interact_type: 2, robot_type: 10000 },
|
| name: (question || "New Chat").slice(0, 50),
|
| msgs_limit: 20,
|
| });
|
| if (data.code === 0) return data.session_id;
|
| throw new Error(`InitSession failed: code=${data.code} msg=${data.msg}`);
|
| }
|
|
|
| function imaQaStream(sessionId, question, modelType, modelId) {
|
| return imaSse("/cgi-bin/assistant/qa", {
|
| session_id: sessionId,
|
| robot_type: 10000,
|
| question,
|
| question_type: 2,
|
| command_info: { question_info: {} },
|
| client_id: crypto.randomUUID(),
|
| model_info: { model_type: modelType, model_id: modelId },
|
| });
|
| }
|
|
|
| async function collectResponseText(events) {
|
| let text = "";
|
| const seen = [];
|
| for await (const evt of events) {
|
| if (CONTROL_EVENTS.has(evt.event)) break;
|
| text += eventText(evt);
|
| if (DEBUG_SSE) seen.push(evt.event || "(none)");
|
| }
|
| if (DEBUG_SSE && !text) console.error(`[SSE-EMPTY] events=${JSON.stringify(seen)}`);
|
| return text;
|
| }
|
|
|
|
|
|
|
|
|
| const sessions = new Map();
|
| const SESSION_TTL = 30 * 60 * 1000;
|
|
|
| function getCachedSession(convId) {
|
| const now = Date.now();
|
| for (const [k, v] of sessions) { if (now - v.ts > SESSION_TTL) sessions.delete(k); }
|
| if (convId && sessions.has(convId)) { sessions.get(convId).ts = now; return sessions.get(convId).id; }
|
| return null;
|
| }
|
|
|
| async function ensureSession(convId, question, forceNew = false) {
|
| if (!forceNew) {
|
| const c = getCachedSession(convId);
|
| if (c) return c;
|
| }
|
| const id = await imaInitSession(question);
|
| if (convId) sessions.set(convId, { id, ts: Date.now() });
|
| return id;
|
| }
|
|
|
|
|
| async function imaQaStreamWithRetry(convId, question, modelType, modelId) {
|
| let sessionId = await ensureSession(convId, question);
|
| const events = await imaQaStream(sessionId, question, modelType, modelId);
|
|
|
| return (async function*() {
|
| let hitLimit = false;
|
| for await (const evt of events) {
|
| if (evt.event === "INNER_EXCEPTION" || evt.event === "ERROR" || evt.event === "FAILED") {
|
|
|
| try {
|
| const newId = await ensureSession(convId, question, true);
|
| const retryEvents = await imaQaStream(newId, question, modelType, modelId);
|
| for await (const e2 of retryEvents) yield e2;
|
| } catch (_) {
|
| yield evt;
|
| }
|
| return;
|
| }
|
| yield evt;
|
| }
|
| })();
|
| }
|
|
|
|
|
|
|
|
|
| const MODELS = CONFIG.models;
|
| const DEFAULT_MODEL = CONFIG.default_model;
|
|
|
| function resolveModel(requested) {
|
| if (!requested) return MODELS[DEFAULT_MODEL];
|
| if (MODELS[requested]) return MODELS[requested];
|
| const lower = requested.toLowerCase();
|
| for (const [k, v] of Object.entries(MODELS)) {
|
| if (k.toLowerCase() === lower || String(v.type) === lower) return v;
|
| }
|
| return MODELS[DEFAULT_MODEL];
|
| }
|
|
|
|
|
|
|
|
|
| function checkAuth(req) {
|
| const bearer = (req.headers["authorization"] || "").replace(/^Bearer\s+/i, "");
|
| if (bearer && API_KEYS.has(bearer)) return true;
|
| return API_KEYS.has(req.headers["x-api-key"] || "");
|
| }
|
|
|
| function cors(res) {
|
| res.setHeader("Access-Control-Allow-Origin", "*");
|
| res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
| res.setHeader("Access-Control-Allow-Headers",
|
| "Content-Type, Authorization, x-api-key, x-conversation-id, x-session-id, x-request-id, x-stainless-*");
|
| }
|
|
|
| function json(res, code, obj) {
|
| cors(res);
|
| res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
| res.end(JSON.stringify(obj));
|
| if (code >= 400) {
|
| console.error(`[RESP] ${code} ${JSON.stringify(obj).slice(0, 200)}`);
|
| }
|
| }
|
|
|
| function extractContent(msg) {
|
| if (typeof msg.content === "string") return msg.content;
|
| if (Array.isArray(msg.content))
|
| return msg.content.map(c => {
|
| if (c.type === "text") return c.text;
|
| if (c.type === "tool_result") {
|
|
|
| const tc = typeof c.content === "string" ? c.content
|
| : Array.isArray(c.content) ? c.content.map(cc => cc.text || "").join("") : "";
|
| return `Tool result:\n${tc}`;
|
| }
|
| if (c.type === "tool_use") return `Tool call: ${c.name}(${JSON.stringify(c.input)})`;
|
| return "";
|
| }).filter(Boolean).join("\n");
|
| return String(msg.content || "");
|
| }
|
|
|
|
|
|
|
|
|
|
|
| function escapeRawCtrlInStrings(s) {
|
| let out = "", inStr = false, esc = false;
|
| for (let i = 0; i < s.length; i++) {
|
| const c = s[i];
|
| if (esc) { out += c; esc = false; continue; }
|
| if (c === "\\") { out += c; esc = true; continue; }
|
| if (c === '"') { inStr = !inStr; out += c; continue; }
|
| if (inStr) {
|
| if (c === "\n") { out += "\\n"; continue; }
|
| if (c === "\r") { out += "\\r"; continue; }
|
| if (c === "\t") { out += "\\t"; continue; }
|
| }
|
| out += c;
|
| }
|
| return out;
|
| }
|
|
|
|
|
| function tryRepairJson(raw) {
|
| try { return JSON.parse(raw); } catch (e) { }
|
| let fixed = raw;
|
|
|
| fixed = escapeRawCtrlInStrings(fixed);
|
| try { return JSON.parse(fixed); } catch (e) { }
|
|
|
| fixed = fixed.replace(/,(\s*[}\]])/g, '$1');
|
| try { return JSON.parse(fixed); } catch (e) { }
|
|
|
| let depth = 0;
|
| for (const c of fixed) {
|
| if (c === '{' || c === '[') depth++;
|
| if (c === '}' || c === ']') depth--;
|
| }
|
| if (depth > 0) {
|
| const lastChar = fixed.trim().slice(-1);
|
| const closer = lastChar === '}' ? ']' : '}';
|
| fixed += closer.repeat(Math.min(depth, 5));
|
| try { return JSON.parse(fixed); } catch (e) { }
|
| }
|
|
|
| const nameMatch = fixed.match(/"name"\s*:\s*"([^"]+)"/);
|
| if (nameMatch) {
|
| const argsStart = fixed.indexOf('{', fixed.indexOf('"arguments"'));
|
| if (argsStart >= 0) {
|
| let d = 0, end = -1;
|
| for (let i = argsStart; i < fixed.length; i++) {
|
| if (fixed[i] === '{' || fixed[i] === '[') d++;
|
| if (fixed[i] === '}' || fixed[i] === ']') { d--; if (d === 0) { end = i + 1; break; } }
|
| }
|
| try { return { name: nameMatch[1], arguments: JSON.parse(fixed.slice(argsStart, end)) }; } catch (e) { }
|
| }
|
| return { name: nameMatch[1], arguments: {} };
|
| }
|
| return null;
|
| }
|
|
|
|
|
| function compactSchema(schema) {
|
| if (!schema || typeof schema !== "object") return "{}";
|
|
|
| function clean(obj) {
|
| if (Array.isArray(obj)) return obj.map(clean);
|
| if (obj && typeof obj === "object") {
|
| const out = {};
|
| for (const [k, v] of Object.entries(obj)) {
|
| if (k.startsWith("$")) continue;
|
| out[k] = clean(v);
|
| }
|
| return out;
|
| }
|
| return obj;
|
| }
|
| return JSON.stringify(clean(schema));
|
| }
|
|
|
| function buildToolsPrompt(tools) {
|
| if (!tools || tools.length === 0) return "";
|
|
|
| const funcDefs = tools
|
| .filter(t => t.type === "function" && t.function)
|
| .map(t => {
|
| const fn = t.function;
|
| const params = compactSchema(fn.parameters);
|
| return `<function name="${fn.name}">
|
| <description>${fn.description || "No description"}</description>
|
| <parameters>${params}</parameters>
|
| </function>`;
|
| })
|
| .join("\n");
|
|
|
| return `## CRITICAL -- YOU MUST USE FUNCTION CALLING
|
|
|
| You have access to these functions. When the user asks you to do something
|
| that a function can handle, you MUST call the function. NEVER say "I cannot"
|
| or "I don't have the ability". NEVER tell the user to save a file, run a
|
| command, or do any step themselves - DO IT by calling the function (e.g. use
|
| Write to create files, Bash to run commands). ALWAYS use a function instead.
|
|
|
| ${funcDefs}
|
|
|
| ## HOW TO CALL A FUNCTION
|
|
|
| Output EXACTLY this format, then STOP:
|
|
|
| <function_call>
|
| {"name": "<function_name>", "arguments": {<args_as_json>}}
|
| </function_call>
|
|
|
| Rules:
|
| - arguments MUST be a valid JSON object matching the function's parameters
|
| - For file content, put the FULL content in the string value (newlines allowed)
|
| - Do NOT add any text before or after the <function_call> block
|
| - NEVER output file content or code for the user to copy - write it via Write`;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| function buildToolsPromptSoft(tools) {
|
| if (!tools || tools.length === 0) return "";
|
|
|
| const funcDefs = tools
|
| .filter(t => t.type === "function" && t.function)
|
| .map(t => {
|
| const fn = t.function;
|
| return `<function name="${fn.name}">
|
| <description>${fn.description || "No description"}</description>
|
| <parameters>${compactSchema(fn.parameters)}</parameters>
|
| </function>`;
|
| })
|
| .join("\n");
|
|
|
| return `## Available functions
|
|
|
| ${funcDefs}
|
|
|
| ## CRITICAL - keep going until the task is fully done
|
|
|
| The user's request may need MULTIPLE steps. After each function result, decide:
|
| the task is NOT finished yet -> call the next function NOW; the task IS fully
|
| finished -> give the final answer in plain text.
|
|
|
| NEVER reply with only your intention (e.g. "Let me read the file", "I'll now
|
| start the server"I will now do it"). Stating intent is NOT an action and NOT an
|
| answer. If you intend to do something, you MUST emit the function call for it
|
| in THIS SAME reply.
|
|
|
| ## To call a function
|
| Output EXACTLY: <function_call>{"name":"...","arguments":{...}}</function_call> then stop.
|
| (For file content, put the FULL content in the string value; newlines allowed.)
|
|
|
| ## To finish
|
| Only when every step is done, reply to the user in plain text WITHOUT any
|
| <function_call> block.`;
|
| }
|
|
|
|
|
|
|
| class StreamFilter {
|
| constructor() {
|
| this._buf = '';
|
| this._emitted = '';
|
| this._funcBlocks = [];
|
| this._inFunc = false;
|
| this._tagLen = 16;
|
| this._leftover = '';
|
| }
|
|
|
|
|
| feed(chunk) {
|
| this._buf += chunk;
|
| const out = [];
|
| const startTag = '<function_call>';
|
| const endTag = '</function_call>';
|
|
|
| while (this._buf.length > 0) {
|
| if (!this._inFunc) {
|
| const idx = this._buf.indexOf(startTag);
|
| if (idx === -1) {
|
|
|
| const partialLen = this._partialMatch(startTag);
|
| if (partialLen > 0) {
|
|
|
| const safe = this._buf.slice(0, -partialLen);
|
| if (safe) out.push(safe);
|
| this._buf = this._buf.slice(-partialLen);
|
| break;
|
| }
|
|
|
| out.push(this._buf);
|
| this._buf = '';
|
| break;
|
| }
|
|
|
| if (idx > 0) out.push(this._buf.slice(0, idx));
|
| this._buf = this._buf.slice(idx + startTag.length);
|
| this._inFunc = true;
|
| }
|
|
|
| if (this._inFunc) {
|
| const idx = this._buf.indexOf(endTag);
|
| if (idx === -1) {
|
|
|
| break;
|
| }
|
|
|
| const json = this._buf.slice(0, idx).trim();
|
| if (json) this._funcBlocks.push(json);
|
| this._buf = this._buf.slice(idx + endTag.length);
|
| this._inFunc = false;
|
|
|
| }
|
| }
|
|
|
| const text = out.join('');
|
| if (text) this._emitted += text;
|
| return text;
|
| }
|
|
|
|
|
| _partialMatch(tag) {
|
| for (let i = tag.length - 1; i > 0; i--) {
|
| if (this._buf.endsWith(tag.slice(0, i))) return i;
|
| }
|
| return 0;
|
| }
|
|
|
|
|
| flush() {
|
| if (this._inFunc) {
|
|
|
|
|
|
|
| const pending = this._buf.trim();
|
| if (pending) this._funcBlocks.push(pending);
|
| this._buf = '';
|
| this._inFunc = false;
|
| return '';
|
| }
|
| if (this._buf) {
|
| this._emitted += this._buf;
|
| const r = this._buf;
|
| this._buf = '';
|
| return r;
|
| }
|
| return '';
|
| }
|
|
|
|
|
| parseCalls() {
|
| const calls = [];
|
| for (const raw of this._funcBlocks) {
|
| const parsed = tryRepairJson(raw);
|
| if (parsed && parsed.name) {
|
| calls.push({
|
| id: 'toolu_' + crypto.randomUUID().slice(0, 12),
|
| type: 'function',
|
| function: {
|
| name: parsed.name || '',
|
| arguments: JSON.stringify(parsed.arguments || parsed.parameters || {}),
|
| },
|
| });
|
| } else if (raw.length > 0) {
|
|
|
|
|
| const note = '\n[Function call (malformed)]:\n' + raw + '\n';
|
| this._emitted += note;
|
| this._leftover += note;
|
| }
|
| }
|
| return calls;
|
| }
|
|
|
| get cleanText() { return this._emitted; }
|
| get hasFunc() { return this._funcBlocks.length > 0; }
|
| get leftover() { return this._leftover; }
|
| }
|
|
|
| function parseFunctionCalls(text) {
|
| if (!text) return { found: false, text: "" };
|
|
|
|
|
| const jsonBlockRegex = /```(?:json)?\s*\n?\s*(\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\})\s*\n?\s*```/g;
|
|
|
| const regex = /<function_call>\s*\n?\s*(\{[\s\S]*?\})\s*\n?\s*<\/function_call>/g;
|
| const calls = [];
|
| let cleanText = text;
|
|
|
|
|
| const patterns = [
|
| regex,
|
| /```(?:json)?\s*\n?\s*\{[^{}]*"name"\s*:\s*"[^"]+"[^{}]*"arguments"\s*:\s*\{[\s\S]*?\}\s*\}\s*\n?\s*```/g,
|
| ];
|
|
|
| for (const pattern of patterns) {
|
| let match;
|
| while ((match = pattern.exec(text)) !== null) {
|
| const parsed = tryRepairJson(match[1].trim());
|
| if (parsed && parsed.name) {
|
| calls.push({
|
| id: "toolu_" + crypto.randomUUID().slice(0, 12),
|
| type: "function",
|
| function: {
|
| name: parsed.name || "",
|
| arguments: JSON.stringify(parsed.arguments || parsed.parameters || {}),
|
| },
|
| });
|
| }
|
| }
|
| if (calls.length > 0) break;
|
| }
|
|
|
| if (calls.length > 0) {
|
| cleanText = text.replace(regex, "").replace(/```(?:json)?\s*\n?\s*\{[\s\S]*?\}\s*\n?\s*```/g, "").trim();
|
| return { found: true, calls, text: cleanText };
|
| }
|
|
|
|
|
| if (text.includes('function') || text.includes('tool') || text.includes('bash') || text.includes('read_file')) {
|
| }
|
| return { found: false, text };
|
| }
|
|
|
|
|
|
|
|
|
| async function openaiChat(req, res, body) {
|
| const modelKey = body.model || DEFAULT_MODEL;
|
| const model = resolveModel(modelKey);
|
| const stream = !!body.stream;
|
| const messages = body.messages || [];
|
| const tools = body.tools || null;
|
| const toolChoice = body.tool_choice || null;
|
|
|
|
|
| const hasToolResults = messages.some(m => m.role === "tool");
|
| const hasToolCalls = messages.some(m => m.role === "assistant" && m.tool_calls);
|
| let question;
|
|
|
|
|
| let effectiveTools = tools;
|
| if ((!effectiveTools || effectiveTools.length === 0) && toolChoice !== "none") {
|
| effectiveTools = [
|
| {type: "function", function: {name: "Bash", description: "Execute bash command", parameters: {type: "object", properties: {command: {type: "string"}}, required: ["command"]}}},
|
| {type: "function", function: {name: "Read", description: "Read a file", parameters: {type: "object", properties: {file_path: {type: "string"}}, required: ["file_path"]}}},
|
| {type: "function", function: {name: "Write", description: "Write to a file", parameters: {type: "object", properties: {file_path: {type: "string"}, content: {type: "string"}}, required: ["file_path", "content"]}}},
|
| {type: "function", function: {name: "Glob", description: "Find files by pattern", parameters: {type: "object", properties: {pattern: {type: "string"}}, required: ["pattern"]}}},
|
| {type: "function", function: {name: "Grep", description: "Search file contents", parameters: {type: "object", properties: {pattern: {type: "string"}, path: {type: "string"}}, required: ["pattern"]}}},
|
| ];
|
| }
|
|
|
| const _sysParts = messages.filter(m => m.role === "system").map(m => extractContent(m));
|
| const _sysPrompt = _sysParts.length > 0 ? _sysParts.join("\n") : "";
|
| const isTitleGenOAI = _sysPrompt.includes('Generate a concise, sentence-case title');
|
|
|
|
|
| const MAX_TOOLS_OAI = 8;
|
| const ESSENTIAL_OAI = new Set(['Bash', 'Read', 'Write', 'Glob', 'Grep']);
|
| if (isTitleGenOAI) {
|
| effectiveTools = [];
|
| } else if (effectiveTools && effectiveTools.length > MAX_TOOLS_OAI) {
|
| const essential = effectiveTools.filter(t => ESSENTIAL_OAI.has(t.function?.name));
|
| const others = effectiveTools.filter(t => !ESSENTIAL_OAI.has(t.function?.name));
|
| const available = MAX_TOOLS_OAI - essential.length;
|
| effectiveTools = [...essential, ...others.slice(0, Math.max(0, available))];
|
| }
|
| const toolsPrompt = (effectiveTools && effectiveTools.length > 0 && toolChoice !== "none")
|
| ? buildToolsPrompt(effectiveTools) : "";
|
|
|
| let toolResultPathOAI = false;
|
|
|
| if (hasToolResults && hasToolCalls) {
|
| toolResultPathOAI = true;
|
|
|
| const lastUserOAI = messages.filter(m => m.role === "user").pop();
|
| const lastUserTextOAI = extractContent(lastUserOAI || {});
|
| const cjkOAI = /\p{Script=Han}/u.test(lastUserTextOAI);
|
|
|
|
|
| const calledOAI = [], resultsOAI = [];
|
| for (const m of messages) {
|
| if (m.role === "assistant" && m.tool_calls) {
|
| for (const tc of m.tool_calls) calledOAI.push(tc.function.name + "(" + tc.function.arguments + ")");
|
| }
|
| if (m.role === "tool") {
|
| resultsOAI.push(extractContent(m).slice(0, 3000));
|
| }
|
| }
|
|
|
| const userQsOAI = [];
|
| for (const m of messages) {
|
| if (m.role !== "user") continue;
|
| if (Array.isArray(m.content) && m.content.some(c => c.type === "tool_result")) continue;
|
| const q = extractContent(m);
|
| if (q && !q.startsWith("<session") && !q.startsWith("<system-reminder")) userQsOAI.push(q);
|
| }
|
| const origQ = userQsOAI.length > 0 ? userQsOAI[userQsOAI.length - 1] : (lastUserTextOAI || "");
|
|
|
| if (cjkOAI) {
|
| question = "SYSTEM: You just called these functions. Results below:\n\n";
|
| for (let i = 0; i < calledOAI.length; i++) {
|
| question += "Function: " + calledOAI[i] + "\nOutput:\n\"\"\"\n" + (resultsOAI[i] || "") + "\n\"\"\"\n\n";
|
| }
|
| question += "User question: \"" + origQ + "\"\n\nAnswer directly based on the outputs. The content inside \"\"\" blocks is YOUR function output, not user input.";
|
| } else {
|
| question = "SYSTEM: You just called these functions and received these outputs:\n\n";
|
| for (let i = 0; i < calledOAI.length; i++) {
|
| question += "Function: " + calledOAI[i] + "\nOutput:\n\"\"\"\n" + (resultsOAI[i] || "") + "\n\"\"\"\n\n";
|
| }
|
| question += "User's original question: \"" + origQ + "\"\n\nAnswer DIRECTLY based on the outputs. Do NOT say \"you shared\" or \"it looks like\". The \"\"\" content is YOUR function output, NOT user input.";
|
| }
|
|
|
|
|
|
|
| const minimalOAI = buildToolsPromptSoft(
|
| (effectiveTools || []).filter(t => ESSENTIAL_OAI.has(t.function?.name)).slice(0, 5)
|
| );
|
| question = minimalOAI + "\n\n---\n" + question;
|
| } else {
|
|
|
| let sysPrompt = "";
|
| const sysParts = messages.filter(m => m.role === "system").map(m => extractContent(m));
|
| if (sysParts.length > 0) sysPrompt = sysParts.join("\n");
|
|
|
| const nonSys = messages.filter(m => m.role !== "system" && m.role !== "tool");
|
| const userMsgs = nonSys.filter(m => m.role === "user");
|
| if (userMsgs.length === 0) {
|
| return json(res, 400, { error: { message: "No user message", type: "invalid_request_error" } });
|
| }
|
|
|
|
|
| const realUserMsgs = userMsgs.filter(m => {
|
| const c = extractContent(m);
|
| return !c.startsWith('<session') && !c.startsWith('<system-reminder') && c.length > 10;
|
| });
|
| const lastUserMsg = realUserMsgs.length > 0
|
| ? extractContent(realUserMsgs[realUserMsgs.length - 1])
|
| : extractContent(userMsgs[userMsgs.length - 1]);
|
|
|
|
|
| const hasCJKOAI = /\p{Script=Han}/u.test(lastUserMsg);
|
| const langHintOAI = hasCJKOAI ? "\n## Language\nRespond in the same language as the user's message. The user is writing in Chinese - respond in Chinese (Simplified Chinese).\n" : "";
|
|
|
|
|
| const MAX_QUESTION = 10000;
|
|
|
| if (nonSys.length > 1 && realUserMsgs.length >= 1) {
|
| const realMsgs = nonSys.filter(m => {
|
| const c = extractContent(m);
|
| return c.length > 10 && !c.startsWith('<session') && !c.startsWith('<system-reminder');
|
| });
|
| if (realMsgs.length > 1) {
|
|
|
| const MAX_HISTORY_CHARS = 6000;
|
| const recentMsgs = realMsgs
|
| .filter(m => m.role === "user" || m.role === "assistant")
|
| .slice(-20);
|
| let historyParts = [];
|
| let historyLen = 0;
|
| for (let i = recentMsgs.length - 1; i >= 0; i--) {
|
| const m = recentMsgs[i];
|
| const line = `${m.role === "user" ? "User" : "Assistant"}: ${extractContent(m)}`;
|
| if (historyLen + line.length > MAX_HISTORY_CHARS && historyParts.length > 0) break;
|
| historyParts.unshift(line);
|
| historyLen += line.length + 1;
|
| }
|
| const history = historyParts.join("\n");
|
| question = (sysPrompt ? sysPrompt + "\n\n" : "") + (toolsPrompt ? toolsPrompt + "\n\n---\n" : "") + history;
|
| } else {
|
| question = `${sysPrompt ? "(Background)\n" + sysPrompt + "\n\n" : ""}${toolsPrompt}\n\n---\nUser message (respond to this):\n${lastUserMsg}`;
|
| }
|
| } else {
|
|
|
| let context = sysPrompt;
|
| const overhead = toolsPrompt.length + lastUserMsg.length + 200;
|
| const remaining = MAX_QUESTION - overhead;
|
|
|
| if (context && context.length > remaining && remaining > 0) {
|
|
|
| const layoutMatch = context.match(/<project_layout>([\s\S]*?)<\/project_layout>/);
|
| const layoutSummary = layoutMatch
|
| ? layoutMatch[1].trim().split("\n").slice(0, 30).join("\n")
|
| : "";
|
| const headLen = Math.min(800, Math.floor(remaining * 0.6));
|
| const tailLen = Math.min(400, remaining - headLen);
|
| const head = context.slice(0, headLen);
|
| const tail = context.length > headLen + tailLen ? context.slice(-tailLen) : "";
|
| const layoutPart = layoutSummary ? "\n\nDirectory (summary):\n" + layoutSummary.slice(0, Math.min(300, remaining - headLen - tailLen - 50)) : "";
|
| context = tail ? head + "\n...[truncated]..." + layoutPart + "\n" + tail : head + layoutPart;
|
| context = context.slice(0, remaining);
|
| }
|
|
|
| question = `${context ? "(Background)\n" + context + "\n\n" : ""}${toolsPrompt}\n\n---\nUser message (respond to this):\n${lastUserMsg}`;
|
| }
|
|
|
|
|
| if (!toolResultPathOAI) {
|
| question += langHintOAI;
|
|
|
|
|
| if (question.length > MAX_QUESTION) {
|
| const minTemplate = "\n\n---\nUser message (respond to this):\n";
|
| const compact = toolsPrompt + minTemplate + lastUserMsg + langHintOAI;
|
| if (compact.length > MAX_QUESTION) {
|
| const availForUser = Math.max(500, MAX_QUESTION - toolsPrompt.length - minTemplate.length - langHintOAI.length);
|
| question = toolsPrompt + minTemplate + lastUserMsg.slice(0, Math.max(0, availForUser)) + langHintOAI;
|
| } else {
|
| question = compact;
|
| }
|
| }
|
| }
|
| }
|
|
|
| if (!question) {
|
| return json(res, 400, { error: { message: "Empty question", type: "invalid_request_error" } });
|
| }
|
|
|
|
|
|
|
|
|
| let oaiConvId = req.headers["x-conversation-id"] || req.headers["x-session-id"] || "";
|
| if (!oaiConvId && messages.length > 0) {
|
| const sysMsg = messages.find(m => m.role === "system");
|
| const anchor = sysMsg
|
| ? extractContent(sysMsg).slice(0, 200)
|
| : extractContent(messages[0]).slice(0, 200);
|
| oaiConvId = "conv-" + crypto.createHash("md5").update(anchor).digest("hex").slice(0, 12);
|
| }
|
| let sessionId;
|
| try {
|
| sessionId = await ensureSession(oaiConvId, question.slice(0, 100));
|
| } catch (e) {
|
| return json(res, 502, { error: { message: "Session init failed: " + e.message, type: "api_error" } });
|
| }
|
|
|
|
|
| if (!stream) {
|
| try {
|
| const events = await imaQaStreamWithRetry(oaiConvId, question, model.type, model.id);
|
| const text = await collectResponseText(events);
|
| const parsed = parseFunctionCalls(text);
|
|
|
| const choice = { index: 0, message: {}, finish_reason: "stop" };
|
|
|
| if (parsed.found && parsed.calls.length > 0) {
|
| choice.message = {
|
| role: "assistant",
|
| content: parsed.text || null,
|
| tool_calls: parsed.calls,
|
| };
|
| choice.finish_reason = "tool_calls";
|
| } else {
|
|
|
| choice.message = { role: "assistant", content: text || "(No content generated. Please retry or rephrase.)" };
|
| }
|
|
|
| return json(res, 200, {
|
| id: "chatcmpl-" + crypto.randomUUID().slice(0, 8),
|
| object: "chat.completion",
|
| created: Math.floor(Date.now() / 1000),
|
| model: modelKey,
|
| choices: [choice],
|
| usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
| });
|
| } catch (e) {
|
| return json(res, 502, { error: { message: "IMA error: " + e.message, type: "api_error" } });
|
| }
|
| }
|
|
|
|
|
| cors(res);
|
| res.writeHead(200, {
|
| "Content-Type": "text/event-stream",
|
| "Cache-Control": "no-cache",
|
| Connection: "keep-alive",
|
| "x-request-id": "chatcmpl-" + crypto.randomUUID().slice(0, 8),
|
| });
|
|
|
| const chatId = "chatcmpl-" + crypto.randomUUID().slice(0, 8);
|
| const created = Math.floor(Date.now() / 1000);
|
| let resClosed = false;
|
| let sentText = false;
|
| res.on('close', () => { resClosed = true; });
|
|
|
| function emit(delta, finishReason, toolCalls) {
|
| if (resClosed) return;
|
| try {
|
| const d = toolCalls && toolCalls.length > 0
|
| ? { role: "assistant", tool_calls: toolCalls }
|
| : { role: "assistant", content: delta };
|
| if ((delta && delta.length > 0) || (toolCalls && toolCalls.length > 0)) sentText = true;
|
| res.write("data: " + JSON.stringify({
|
| id: chatId, object: "chat.completion.chunk", created, model: modelKey,
|
| choices: [{ index: 0, delta: d, finish_reason: finishReason }],
|
| }) + "\n\n");
|
| } catch (e) { resClosed = true; }
|
| }
|
|
|
| try {
|
| const events = await imaQaStreamWithRetry(oaiConvId, question, model.type, model.id);
|
| const filter = new StreamFilter();
|
| let done = false;
|
|
|
| let sawEvent = false;
|
| for await (const evt of events) {
|
| if (resClosed) break;
|
| if (CONTROL_EVENTS.has(evt.event)) {
|
| if (evt.event === "COMPLETED" || evt.event === "CLOSE") done = true;
|
| break;
|
| }
|
| sawEvent = true;
|
| const txt = eventText(evt);
|
| if (txt) {
|
| const clean = filter.feed(txt);
|
| if (clean) emit(clean, null, null);
|
| }
|
| }
|
| if (DEBUG_SSE && !sawEvent) console.error("[SSE-EMPTY] openai stream: no data events");
|
|
|
|
|
| const flushed = filter.flush();
|
| if (flushed && !resClosed) emit(flushed, null, null);
|
|
|
|
|
| if (!resClosed) {
|
| const calls = filter.parseCalls();
|
|
|
| if (filter.leftover) emit(filter.leftover, null, null);
|
| if (calls.length > 0) {
|
|
|
| for (let i = 0; i < calls.length; i++) {
|
| const tc = calls[i];
|
| if (resClosed) break;
|
| try {
|
| sentText = true;
|
| res.write("data: " + JSON.stringify({
|
| id: chatId, object: "chat.completion.chunk", created, model: modelKey,
|
| choices: [{ index: 0, delta: { tool_calls: [{ index: i, id: tc.id, type: "function", function: { name: tc.function.name, arguments: tc.function.arguments } }] }, finish_reason: null }],
|
| }) + "\n\n");
|
| } catch (e) { resClosed = true; }
|
| }
|
| if (!resClosed) {
|
| res.write("data: " + JSON.stringify({
|
| id: chatId, object: "chat.completion.chunk", created, model: modelKey,
|
| choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
| }) + "\n\n");
|
| }
|
| } else {
|
|
|
|
|
| if (!sentText && !resClosed) {
|
| const fb = done
|
| ? "(No content generated. Please retry or rephrase.)"
|
| : "(Response interrupted. Please retry.)";
|
| emit(fb, null, null);
|
| }
|
|
|
| res.write("data: " + JSON.stringify({
|
| id: chatId, object: "chat.completion.chunk", created, model: modelKey,
|
| choices: [{ index: 0, delta: {}, finish_reason: done ? "stop" : "length" }],
|
| }) + "\n\n");
|
| }
|
| }
|
| } catch (e) {
|
| if (!resClosed) {
|
| try { res.write("data: " + JSON.stringify({
|
| id: chatId, object: "chat.completion.chunk", created, model: modelKey,
|
| choices: [{ index: 0, delta: {}, finish_reason: "error" }],
|
| }) + "\n\n"); } catch {}
|
| }
|
| console.error(`[STREAM-ERR] ${e.message}`);
|
| }
|
| if (!resClosed) {
|
| try { res.write("data: [DONE]\n\n"); res.end(); } catch {}
|
| }
|
| }
|
|
|
|
|
|
|
|
|
| async function anthropicMessages(req, res, body) {
|
| const modelKey = body.model || DEFAULT_MODEL;
|
| const model = resolveModel(modelKey);
|
| const stream = !!body.stream;
|
| const messages = body.messages || [];
|
|
|
|
|
| let sysPrompt = "";
|
| if (typeof body.system === "string") sysPrompt = body.system;
|
| else if (Array.isArray(body.system))
|
| sysPrompt = body.system.filter(s => s.type === "text").map(s => s.text).join("\n");
|
|
|
|
|
|
|
| let tools = body.tools;
|
| if (!tools || tools.length === 0) {
|
| tools = [
|
| {name: "Bash", description: "Execute a bash command. Use for: reading files (cat/ls), system info (uname/df/free), git, npm, find, grep, etc.", input_schema: {type: "object", properties: {command: {type: "string", description: "The bash command to execute"}}, required: ["command"]}},
|
| {name: "Read", description: "Read contents of a file. Use for: inspecting file contents, reading configs, source code.", input_schema: {type: "object", properties: {file_path: {type: "string", description: "Absolute path to the file"}}, required: ["file_path"]}},
|
| {name: "Write", description: "Write content to a file. Use for: creating new files, overwriting existing files.", input_schema: {type: "object", properties: {file_path: {type: "string", description: "Absolute path"}, content: {type: "string", description: "Content to write"}}, required: ["file_path", "content"]}},
|
| {name: "Glob", description: "Find files matching a pattern. Use for: searching for files by name.", input_schema: {type: "object", properties: {pattern: {type: "string", description: "Glob pattern like **/*.js"}}, required: ["pattern"]}},
|
| {name: "Grep", description: "Search file contents for a pattern. Use for: finding code, searching logs.", input_schema: {type: "object", properties: {pattern: {type: "string", description: "Regex pattern to search for"}, path: {type: "string", description: "Directory or file to search in"}}, required: ["pattern"]}},
|
| ];
|
| }
|
|
|
|
|
| const isTitleGen = sysPrompt.includes('Generate a concise, sentence-case title');
|
|
|
|
|
| const MAX_TOOLS = 8;
|
| const ESSENTIAL_TOOLS = new Set(['Bash', 'Read', 'Write', 'Glob', 'Grep']);
|
| let displayTools = tools;
|
| if (isTitleGen) {
|
| displayTools = [];
|
| } else if (displayTools.length > MAX_TOOLS) {
|
| const essential = displayTools.filter(t => ESSENTIAL_TOOLS.has(t.name));
|
| const others = displayTools.filter(t => !ESSENTIAL_TOOLS.has(t.name));
|
| const available = MAX_TOOLS - essential.length;
|
| displayTools = [...essential, ...others.slice(0, Math.max(0, available))];
|
| }
|
|
|
|
|
| function msgHasType(msg, type) {
|
| if (Array.isArray(msg.content)) return msg.content.some(c => c.type === type);
|
| return false;
|
| }
|
| const hasToolUses = messages.some(m => m.role === "assistant" && msgHasType(m, "tool_use"));
|
| const hasToolResults = messages.some(m => m.role === "user" && msgHasType(m, "tool_result"));
|
|
|
| function stripMetadata(text) {
|
| return text
|
|
|
| .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
|
| .replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, '')
|
| .replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>/g, '')
|
| .replace(/<command-name>[^<]*<\/command-name>/g, '')
|
| .replace(/<command-message>[^<]*<\/command-message>/g, '')
|
| .replace(/<command-args>[^<]*<\/command-args>/g, '')
|
|
|
| .replace(/<\/?session>/g, '')
|
| .trim();
|
| }
|
|
|
| const nonSys = messages.filter(m => m.role !== "system");
|
| const userMsgs = nonSys.filter(m => m.role === "user");
|
| if (userMsgs.length === 0)
|
| return json(res, 400, { type: "error", error: { type: "invalid_request_error", message: "No user message" } });
|
|
|
| const realUserMsgs = userMsgs.filter(m => {
|
| const c = stripMetadata(extractContent(m));
|
| return c.length > 5;
|
| });
|
| const lastUserMsg = realUserMsgs.length > 0
|
| ? stripMetadata(extractContent(realUserMsgs[realUserMsgs.length - 1]))
|
| : stripMetadata(extractContent(userMsgs[userMsgs.length - 1]));
|
|
|
| if (!lastUserMsg) {
|
| }
|
|
|
|
|
| const hasCJK = /\p{Script=Han}/u.test(lastUserMsg);
|
| const langHint = hasCJK ? "\n## Language\nRespond in the same language as the user's message. The user is writing in Chinese - respond in Chinese (Simplified Chinese).\n" : "";
|
|
|
|
|
| const toolsPrompt = (displayTools && displayTools.length > 0) ? buildToolsPrompt(
|
| displayTools.map(t => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.input_schema || {} } }))
|
| ) : "";
|
|
|
| let question;
|
| let toolResultPath = false;
|
| const MAX_QUESTION = 10000;
|
|
|
| if (hasToolResults && hasToolUses) {
|
| toolResultPath = true;
|
|
|
| const isCJK = hasCJK;
|
|
|
|
|
| const calledFuncs = [];
|
| const results = [];
|
| for (const m of messages) {
|
| if (m.role === "assistant" && Array.isArray(m.content)) {
|
| for (const c of m.content) {
|
| if (c.type === "tool_use") calledFuncs.push(c.name + "(" + JSON.stringify(c.input) + ")");
|
| }
|
| }
|
| if (m.role === "user" && Array.isArray(m.content)) {
|
| for (const c of m.content) {
|
| if (c.type === "tool_result") {
|
| const tc = typeof c.content === "string" ? c.content
|
| : Array.isArray(c.content) ? c.content.map(cc => cc.text || "").join("") : "";
|
| results.push(tc);
|
| }
|
| }
|
| }
|
| }
|
|
|
|
|
| const userQs = [];
|
| for (const m of messages) {
|
| if (m.role !== "user") continue;
|
| if (Array.isArray(m.content) && m.content.some(c => c.type === "tool_result")) continue;
|
| const q = stripMetadata(extractContent(m));
|
| if (q) userQs.push(q);
|
| }
|
| const originalQuestion = userQs.length > 0 ? userQs[userQs.length - 1] : (lastUserMsg || "");
|
|
|
|
|
| if (isCJK) {
|
| question = "SYSTEM: You just called these functions. Results below:\n\n";
|
| for (let i = 0; i < calledFuncs.length; i++) {
|
| question += "Function: " + calledFuncs[i] + "\n";
|
| question += "Output:\n\"\"\"\n" + (results[i] || "") + "\n\"\"\"\n\n";
|
| }
|
| question += "User question: \"" + originalQuestion + "\"\n\n";
|
| question += "Answer directly based on the outputs. Do NOT say \"you shared\" or \"it looks like\" or \"I see you've shared\". The content inside \"\"\" blocks is YOUR function output, not user-provided content.";
|
| } else {
|
| question = "SYSTEM: You just called these functions and received these outputs:\n\n";
|
| for (let i = 0; i < calledFuncs.length; i++) {
|
| question += "Function: " + calledFuncs[i] + "\n";
|
| question += "Output:\n\"\"\"\n" + (results[i] || "") + "\n\"\"\"\n\n";
|
| }
|
| question += "User's original question: \"" + originalQuestion + "\"\n\n";
|
| question += "Answer the user's question DIRECTLY based on the outputs above. Do NOT say \"you shared\" or \"I see you've shared\" or \"it looks like\". The content inside \"\"\" blocks is YOUR function output, NOT content the user sent you.";
|
| }
|
|
|
|
|
|
|
|
|
| const minimalToolsPrompt = buildToolsPromptSoft(
|
| displayTools.filter(t => ESSENTIAL_TOOLS.has(t.name)).slice(0, 5)
|
| .map(t => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.input_schema || {} } }))
|
| );
|
| question = minimalToolsPrompt + "\n\n---\n" + question;
|
|
|
| } else if (nonSys.length > 1 && realUserMsgs.length >= 1) {
|
| const realMsgs = nonSys
|
| .filter(m => {
|
| const c = stripMetadata(extractContent(m));
|
| return c.length > 5;
|
| });
|
| if (realMsgs.length > 1) {
|
|
|
| const MAX_HISTORY_CHARS = 6000;
|
| const recentMsgs = realMsgs.slice(-20);
|
| let historyParts = [];
|
| let historyLen = 0;
|
| for (let i = recentMsgs.length - 1; i >= 0; i--) {
|
| const m = recentMsgs[i];
|
| const line = (m.role === "user" ? "User" : "Assistant") + ": " + stripMetadata(extractContent(m));
|
| if (historyLen + line.length > MAX_HISTORY_CHARS && historyParts.length > 0) break;
|
| historyParts.unshift(line);
|
| historyLen += line.length + 1;
|
| }
|
| question = historyParts.join("\n");
|
| question = (sysPrompt ? sysPrompt + "\n\n" : "") + (toolsPrompt ? toolsPrompt + "\n\n---\n" : "") + question;
|
| } else {
|
| question = (sysPrompt ? "(Background)\n" + sysPrompt + "\n\n" : "") + toolsPrompt + "\n\n---\nUser message (respond to this):\n" + lastUserMsg;
|
| }
|
| } else {
|
| let context = sysPrompt;
|
| const overhead = toolsPrompt.length + lastUserMsg.length + 200;
|
| const remaining = MAX_QUESTION - overhead;
|
| if (context && context.length > remaining && remaining > 0) {
|
|
|
|
|
| const layoutMatch = context.match(/<project_layout>([\s\S]*?)<\/project_layout>/);
|
| const layoutSummary = layoutMatch ? layoutMatch[1].trim().split("\n").slice(0, 30).join("\n") : "";
|
| const headLen = Math.min(800, Math.floor(remaining * 0.6));
|
| const tailLen = Math.min(400, remaining - headLen);
|
| const head = context.slice(0, headLen);
|
| const tail = context.length > headLen + tailLen ? context.slice(-tailLen) : "";
|
| const layoutPart = layoutSummary ? "\n\nDirectory (summary):\n" + layoutSummary.slice(0, Math.min(300, remaining - headLen - tailLen - 50)) : "";
|
| context = tail ? head + "\n...[truncated]..." + layoutPart + "\n" + tail : head + layoutPart;
|
| context = context.slice(0, remaining);
|
| }
|
| question = (context ? "(Background)\n" + context + "\n\n" : "") + toolsPrompt + "\n\n---\nUser message (respond to this):\n" + lastUserMsg;
|
| }
|
|
|
|
|
| if (!toolResultPath) {
|
| question += langHint;
|
|
|
| if (question.length > MAX_QUESTION) {
|
| const minTemplate = "\n\n---\nUser message (respond to this):\n";
|
| const compact = toolsPrompt + minTemplate + lastUserMsg + langHint;
|
| if (compact.length > MAX_QUESTION) {
|
| const availForUser = Math.max(500, MAX_QUESTION - toolsPrompt.length - minTemplate.length - langHint.length);
|
| question = toolsPrompt + minTemplate + lastUserMsg.slice(0, Math.max(0, availForUser)) + langHint;
|
| } else {
|
| question = compact;
|
| }
|
| }
|
| }
|
|
|
|
|
|
|
| const convId = req.headers["x-conversation-id"] || req.headers["x-session-id"] || "";
|
|
|
|
|
| let effectiveConvId = convId;
|
| if (!effectiveConvId && messages.length > 0) {
|
|
|
| const sysMsg = messages.find(m => m.role === "system");
|
| const anchor = sysMsg
|
| ? extractContent(sysMsg).slice(0, 200)
|
| : extractContent(messages[0]).slice(0, 200);
|
| effectiveConvId = "conv-" + crypto.createHash("md5").update(anchor).digest("hex").slice(0, 12);
|
| }
|
| let sessionId;
|
| const isNewSession = !getCachedSession(effectiveConvId);
|
| try { sessionId = await ensureSession(effectiveConvId, question.slice(0, 100)); }
|
| catch (e) {
|
| console.error(`[ANTHROPIC-SESSION] init failed: ${e.message}`);
|
| return json(res, 502, { type: "error", error: { type: "api_error", message: "Session init failed: " + e.message } });
|
| }
|
|
|
|
|
| if (!stream) {
|
| try {
|
| const events = await imaQaStreamWithRetry(effectiveConvId, question, model.type, model.id);
|
| const text = await collectResponseText(events);
|
| const parsed = parseFunctionCalls(text);
|
|
|
| if (parsed.found && parsed.calls.length > 0) {
|
| const content = [];
|
| if (parsed.text) content.push({ type: "text", text: parsed.text });
|
| for (const c of parsed.calls) {
|
| content.push({
|
| type: "tool_use", id: c.id,
|
| name: c.function.name,
|
| input: JSON.parse(c.function.arguments || "{}"),
|
| });
|
| }
|
| return json(res, 200, {
|
| id: "msg_" + crypto.randomUUID().slice(0, 8),
|
| type: "message", role: "assistant", model: modelKey,
|
| content, stop_reason: "tool_use", stop_sequence: null,
|
| usage: { input_tokens: 0, output_tokens: 0 },
|
| });
|
| }
|
|
|
| return json(res, 200, {
|
| id: "msg_" + crypto.randomUUID().slice(0, 8),
|
| type: "message", role: "assistant", model: modelKey,
|
|
|
| content: [{ type: "text", text: text || "(No content generated. Please retry or rephrase.)" }],
|
| stop_reason: "end_turn", stop_sequence: null,
|
| usage: { input_tokens: 0, output_tokens: 0 },
|
| });
|
| } catch (e) {
|
| console.error(`[ANTHROPIC-NONSTREAM-ERR] ${e.message}`);
|
| return json(res, 502, { type: "error", error: { type: "api_error", message: "IMA error: " + e.message } });
|
| }
|
| }
|
|
|
|
|
| cors(res);
|
| res.writeHead(200, {
|
| "Content-Type": "text/event-stream",
|
| "Cache-Control": "no-cache",
|
| Connection: "keep-alive",
|
| });
|
|
|
| const msgId = "msg_" + crypto.randomUUID().slice(0, 8);
|
| let resClosed = false;
|
| res.on('close', () => { resClosed = true; });
|
|
|
| const em = (e, d) => {
|
| if (resClosed) return;
|
| try {
|
| if (e) res.write(`event: ${e}\n`);
|
| res.write(`data: ${JSON.stringify(d)}\n\n`);
|
| } catch (_) { resClosed = true; }
|
| };
|
|
|
| em("message_start", {
|
| type: "message_start",
|
| message: { id: msgId, type: "message", role: "assistant", model: modelKey, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } },
|
| });
|
| em("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } });
|
|
|
| let stopReason = "end_turn";
|
| let done = false;
|
| let sentText = false;
|
| const filter = new StreamFilter();
|
| let calls = [];
|
| try {
|
| const events = await imaQaStreamWithRetry(effectiveConvId, question, model.type, model.id);
|
|
|
| for await (const evt of events) {
|
| if (resClosed) break;
|
| if (CONTROL_EVENTS.has(evt.event)) {
|
| if (evt.event === "COMPLETED" || evt.event === "CLOSE") done = true;
|
| break;
|
| }
|
| const txt = eventText(evt);
|
| if (txt) {
|
| const clean = filter.feed(txt);
|
| if (clean) { em("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: clean } }); sentText = true; }
|
| }
|
| }
|
|
|
|
|
| const flushed = filter.flush();
|
| if (flushed && !resClosed) { em("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: flushed } }); sentText = true; }
|
|
|
| calls = filter.parseCalls();
|
|
|
| if (filter.leftover && !resClosed) { em("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: filter.leftover } }); sentText = true; }
|
|
|
|
|
|
|
| if (!sentText && calls.length === 0 && !resClosed) {
|
| const fb = done
|
| ? "(No content generated. Please retry or rephrase.)"
|
| : "(Response interrupted. Please retry.)";
|
| em("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: fb } });
|
| sentText = true;
|
| }
|
| } catch (e) {
|
| console.error(`[ANTHROPIC-STREAM-ERR] ${e.message}`);
|
|
|
| if (!sentText && !resClosed) {
|
| try { em("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "(Response error. Please retry.)" } }); sentText = true; } catch {}
|
| }
|
| }
|
|
|
|
|
| if (!resClosed) em("content_block_stop", { type: "content_block_stop", index: 0 });
|
|
|
|
|
| if (!resClosed && calls.length > 0) {
|
| stopReason = "tool_use";
|
| for (let i = 0; i < calls.length; i++) {
|
| const c = calls[i];
|
| const idx = i + 1;
|
| if (resClosed) break;
|
| em("content_block_start", { type: "content_block_start", index: idx, content_block: { type: "tool_use", id: c.id, name: c.function.name, input: {} } });
|
| em("content_block_delta", { type: "content_block_delta", index: idx, delta: { type: "input_json_delta", partial_json: c.function.arguments } });
|
| em("content_block_stop", { type: "content_block_stop", index: idx });
|
| }
|
| }
|
|
|
| if (!resClosed) {
|
| em("message_delta", { type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: null }, usage: { output_tokens: 0 } });
|
| em("message_stop", { type: "message_stop" });
|
| try { res.end(); } catch {}
|
| }
|
| }
|
|
|
|
|
|
|
|
|
| async function router(req, res) {
|
| cors(res);
|
| if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
|
|
|
| const url = req.url;
|
| const urlPath = url.split("?")[0];
|
|
|
| if (urlPath === "/health") return json(res, 200, { status: "ok" });
|
| if (urlPath === "/") return json(res, 200, {
|
| service: "ima2api",
|
| version: "3.0.0",
|
| endpoints: {
|
| openai: ["POST /v1/chat/completions (tools / function calling)", "GET /v1/models"],
|
| anthropic: ["POST /v1/messages (tools / tool_use)"],
|
| },
|
| models: Object.keys(MODELS),
|
| auth: "Bearer <api_key> or x-api-key header",
|
| });
|
|
|
| if (!checkAuth(req)) {
|
| return json(res, 401, { error: { type: "authentication_error", message: "Invalid API key" } });
|
| }
|
|
|
| let body = {};
|
| if (req.method === "POST") {
|
| try {
|
| const raw = await new Promise((resolve, reject) => {
|
| const chunks = [];
|
| req.on("data", c => chunks.push(c));
|
| req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
| req.on("error", reject);
|
| });
|
| body = raw ? JSON.parse(raw) : {};
|
|
|
|
|
| } catch (e) {
|
| return json(res, 400, { error: { type: "invalid_request_error", message: "Invalid JSON body" } });
|
| }
|
| }
|
|
|
| if (req.method === "GET" && urlPath === "/v1/models") {
|
| const modelList = Object.entries(MODELS).map(([id, info]) => ({
|
| id, object: "model", created: 1700000000, owned_by: "ima",
|
| type: "model", display_name: info.name,
|
| created_at: "2024-01-01T00:00:00Z",
|
| }));
|
| const ids = modelList.map(m => m.id);
|
| return json(res, 200, {
|
| object: "list",
|
| data: modelList,
|
| has_more: false,
|
| first_id: ids[0] || null,
|
| last_id: ids[ids.length - 1] || null,
|
| });
|
| }
|
|
|
|
|
| if (req.method === "GET" && urlPath.startsWith("/v1/models/")) {
|
| const modelId = url.slice("/v1/models/".length);
|
| const model = resolveModel(modelId);
|
| if (!model) return json(res, 404, { error: { type: "error", error: { type: "not_found_error", message: `Model not found: ${modelId}` } } });
|
| return json(res, 200, {
|
| id: modelId,
|
| type: "model",
|
| display_name: model.name,
|
| created_at: "2024-01-01T00:00:00Z",
|
| });
|
| }
|
|
|
| if (req.method === "POST" && urlPath === "/v1/chat/completions") {
|
| try { return await openaiChat(req, res, body); }
|
| catch (e) {
|
| console.error(`[OPENAI-FATAL] ${e.message}\n${e.stack}`);
|
| if (!res.headersSent) return json(res, 500, { error: { type: "api_error", message: e.message } });
|
| else { try { res.end(); } catch {} }
|
| }
|
| }
|
| if (req.method === "POST" && urlPath === "/v1/messages") {
|
| try { return await anthropicMessages(req, res, body); }
|
| catch (e) {
|
| console.error(`[ANTHROPIC-FATAL] ${e.message}\n${e.stack}`);
|
| if (!res.headersSent) return json(res, 500, { error: { type: "api_error", message: e.message } });
|
| else { try { res.end(); } catch {} }
|
| }
|
| }
|
|
|
| json(res, 404, { error: { message: `Not found: ${req.method} ${url}` } });
|
| }
|
|
|
|
|
|
|
|
|
| const PORT = process.env.PORT || CONFIG.server?.port || 8080;
|
| const HOST = process.env.HOST || CONFIG.server?.host || "0.0.0.0";
|
|
|
| http.createServer(router).listen(PORT, HOST, () => {
|
| console.log("");
|
| console.log("==================================================");
|
| console.log(`[STARTUP] Listening on http://${HOST}:${PORT}`);
|
| console.log(`[STARTUP] API keys: ${API_KEYS.size} (source: ${process.env.IMA_API_KEYS ? "env" : "config"})`);
|
| for (const k of API_KEYS) console.log(`[STARTUP] key: ${maskKey(k)} (len=${k.length})`);
|
| console.log("==================================================");
|
| console.log("");
|
| });
|
|
|