diff --git a/cli/package.json b/cli/package.json index e7bd1c1..6c907c1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.4.50", + "version": "0.4.52", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/cli/src/cli/api/client.js b/cli/src/cli/api/client.js index cd9ad40..7e70c99 100644 --- a/cli/src/cli/api/client.js +++ b/cli/src/cli/api/client.js @@ -1,6 +1,9 @@ const http = require("http"); const https = require("https"); const crypto = require("crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); const { machineIdSync } = require("node-machine-id"); // Default configuration @@ -12,18 +15,34 @@ const DEFAULT_CONFIG = { const CLI_TOKEN_HEADER = "x-9r-cli-token"; const CLI_TOKEN_SALT = "9r-cli-auth"; +const APP_NAME = "9router"; + +function getDataDir() { + if (process.env.DATA_DIR) return process.env.DATA_DIR; + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), APP_NAME); + } + return path.join(os.homedir(), `.${APP_NAME}`); +} + +const MACHINE_ID_FILE = path.join(getDataDir(), "machine-id"); let config = { ...DEFAULT_CONFIG }; let cachedCliToken = null; +// Read raw machineId from shared file (written by server) → guarantees token match +function loadRawMachineId() { + try { + const raw = fs.readFileSync(MACHINE_ID_FILE, "utf8").trim(); + if (raw) return raw; + } catch {} + try { return machineIdSync(); } catch { return ""; } +} + function getCliToken() { if (cachedCliToken !== null) return cachedCliToken; - try { - const mid = machineIdSync(); - cachedCliToken = crypto.createHash("sha256").update(mid + CLI_TOKEN_SALT).digest("hex").substring(0, 16); - } catch { - cachedCliToken = ""; - } + const raw = loadRawMachineId(); + cachedCliToken = raw ? crypto.createHash("sha256").update(raw + CLI_TOKEN_SALT).digest("hex").substring(0, 16) : ""; return cachedCliToken; } diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index 6bfb085..2c329d6 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -29,10 +29,24 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) // Group items by conversation turn let currentAssistantMsg = null; let pendingToolResults = []; + let pendingReasoning = ""; const inputItems = normalizeResponsesInput(body.input); if (!inputItems) return body; + // Extract reasoning text from summary[].text or encrypted_content fallback + const extractReasoningText = (item) => { + if (Array.isArray(item.summary)) { + const txt = item.summary.map(s => s?.text || "").filter(Boolean).join("\n"); + if (txt) return txt; + } + if (Array.isArray(item.content)) { + const txt = item.content.map(c => c?.text || "").filter(Boolean).join("\n"); + if (txt) return txt; + } + return ""; + }; + for (const item of inputItems) { // Determine item type - Droid CLI sends role-based items without 'type' field // Fallback: if no type but has role property, treat as message @@ -64,7 +78,13 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) return c; }) : item.content; - result.messages.push({ role: item.role, content }); + const msg = { role: item.role, content }; + // Attach buffered reasoning to assistant turn (required by xiaomi-mimo thinking mode) + if (item.role === "assistant" && pendingReasoning) { + msg.reasoning_content = pendingReasoning; + } + pendingReasoning = ""; + result.messages.push(msg); } else if (itemType === "function_call") { // Start or append to assistant message with tool_calls @@ -74,6 +94,10 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) content: null, tool_calls: [] }; + if (pendingReasoning) { + currentAssistantMsg.reasoning_content = pendingReasoning; + pendingReasoning = ""; + } } // Skip items with empty/missing name — Codex/OpenAI reject nameless tool calls (#444) if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue; @@ -107,7 +131,9 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) }); } else if (itemType === "reasoning") { - // Skip reasoning items - they are for display only + // Buffer reasoning text; attached to next assistant message/function_call + const txt = extractReasoningText(item); + if (txt) pendingReasoning = pendingReasoning ? `${pendingReasoning}\n${txt}` : txt; continue; } } diff --git a/package.json b/package.json index 585d487..ecf204f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.50", + "version": "0.4.52", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/lib/db/migrate.js b/src/lib/db/migrate.js index e2551e0..36183c9 100644 --- a/src/lib/db/migrate.js +++ b/src/lib/db/migrate.js @@ -14,6 +14,30 @@ const MIGRATED_MARKER = path.join(DB_DIR, ".migrated-from-json"); // Track per-adapter so reusing same adapter skips re-run, but new adapter (after reset) re-runs. const _migratedAdapters = new WeakSet(); +// Thrown when row-count assertion fails. Outer transaction rolls back, +// legacy db.json kept intact, marker not written → next boot retries. +export class MigrationAborted extends Error { + constructor(message, droppedRows) { + super(message); + this.name = "MigrationAborted"; + this.droppedRows = droppedRows; + } +} + +// Insert rows one-by-one, collect failures, then assert COUNT(*) matches input length. +function importWithAssertion(adapter, tableName, rows, insertFn, rowMeta) { + const dropped = []; + for (const row of rows) { + try { insertFn(row); } + catch (err) { dropped.push({ ...rowMeta(row), reason: err.message }); } + } + const inserted = adapter.get(`SELECT COUNT(*) as c FROM ${tableName}`)?.c ?? 0; + if (inserted !== rows.length) { + console.warn(`[DB][migrate] ${tableName} row-count mismatch: expected ${rows.length}, got ${inserted}. Dropped:`, dropped); + throw new MigrationAborted(`${tableName} row-count mismatch: expected ${rows.length}, got ${inserted}`, dropped); + } +} + function readJsonSafe(file) { if (!fs.existsSync(file)) return null; try { return JSON.parse(fs.readFileSync(file, "utf-8")); } catch { return null; } @@ -91,39 +115,45 @@ function importLegacyMain(adapter, data) { if (data.settings) { adapter.run(`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`, [stringifyJson(data.settings)]); } - for (const c of data.providerConnections || []) { + + importWithAssertion(adapter, "providerConnections", data.providerConnections || [], (c) => { const { id, provider, authType, name, email, priority, isActive, createdAt, updatedAt, ...rest } = c; adapter.run( `INSERT OR REPLACE INTO providerConnections(id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, provider, authType || "oauth", name || null, email || null, priority || null, isActive === false ? 0 : 1, stringifyJson(rest), createdAt || new Date().toISOString(), updatedAt || new Date().toISOString()] ); - } - for (const n of data.providerNodes || []) { + }, (c) => ({ id: c.id ?? null, provider: c.provider ?? null, name: c.name ?? null })); + + importWithAssertion(adapter, "providerNodes", data.providerNodes || [], (n) => { const { id, type, name, createdAt, updatedAt, ...rest } = n; adapter.run( `INSERT OR REPLACE INTO providerNodes(id, type, name, data, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`, [id, type || null, name || null, stringifyJson(rest), createdAt || new Date().toISOString(), updatedAt || new Date().toISOString()] ); - } - for (const p of data.proxyPools || []) { + }, (n) => ({ id: n.id ?? null, type: n.type ?? null, name: n.name ?? null })); + + importWithAssertion(adapter, "proxyPools", data.proxyPools || [], (p) => { const { id, isActive, testStatus, createdAt, updatedAt, ...rest } = p; adapter.run( `INSERT OR REPLACE INTO proxyPools(id, isActive, testStatus, data, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`, [id, isActive === false ? 0 : 1, testStatus || "unknown", stringifyJson(rest), createdAt || new Date().toISOString(), updatedAt || new Date().toISOString()] ); - } - for (const k of data.apiKeys || []) { + }, (p) => ({ id: p.id ?? null })); + + importWithAssertion(adapter, "apiKeys", data.apiKeys || [], (k) => { adapter.run( `INSERT OR REPLACE INTO apiKeys(id, key, name, machineId, isActive, createdAt) VALUES(?, ?, ?, ?, ?, ?)`, [k.id, k.key, k.name || null, k.machineId || null, k.isActive === false ? 0 : 1, k.createdAt || new Date().toISOString()] ); - } - for (const c of data.combos || []) { + }, (k) => ({ id: k.id ?? null, name: k.name ?? null })); + + importWithAssertion(adapter, "combos", data.combos || [], (c) => { adapter.run( `INSERT OR REPLACE INTO combos(id, name, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`, [c.id, c.name, c.kind || null, stringifyJson(c.models || []), c.createdAt || new Date().toISOString(), c.updatedAt || new Date().toISOString()] ); - } + }, (c) => ({ id: c.id ?? null, name: c.name ?? null })); + for (const [alias, model] of Object.entries(data.modelAliases || {})) { adapter.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('modelAliases', ?, ?)`, [alias, stringifyJson(model)]); } @@ -210,14 +240,22 @@ export async function runMigrationOnce(adapter) { const backupDir = makeBackupDir("migrate-from-json"); for (const f of Object.values(LEGACY_FILES)) backupFile(f, backupDir); - adapter.transaction(() => { - importLegacyMain(adapter, legacyMain); - importLegacyUsage(adapter, legacyUsage); - importLegacyDisabled(adapter, legacyDisabled); - importLegacyDetails(adapter, legacyDetails); - setMetaSync(adapter, "appVersion", getAppVersion()); - setMetaSync(adapter, "migratedAt", new Date().toISOString()); - }); + try { + adapter.transaction(() => { + importLegacyMain(adapter, legacyMain); + importLegacyUsage(adapter, legacyUsage); + importLegacyDisabled(adapter, legacyDisabled); + importLegacyDetails(adapter, legacyDetails); + setMetaSync(adapter, "appVersion", getAppVersion()); + setMetaSync(adapter, "migratedAt", new Date().toISOString()); + }); + } catch (err) { + if (err instanceof MigrationAborted) { + console.error(`[DB][migrate] aborted: ${err.message} | legacy JSON kept | backup: ${backupDir}`); + return; + } + throw err; + } try { fs.writeFileSync(MIGRATED_MARKER, new Date().toISOString()); } catch {} pruneOldBackups(); diff --git a/src/mitm/config.js b/src/mitm/config.js index b1396e0..f047106 100644 --- a/src/mitm/config.js +++ b/src/mitm/config.js @@ -1,5 +1,7 @@ // All intercepted domains + URL patterns per tool +const IS_DEV = process.env.NODE_ENV === "development"; + const TARGET_HOSTS = [ "daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com", @@ -20,6 +22,19 @@ const MODEL_SYNONYMS = { antigravity: { "gemini-default": "gemini-3-flash" }, }; +// Pattern fallback: rawModel regex → canonical alias key (when exact + prefix match fail) +// Order matters: more specific patterns first. Catches AG renamed variants (e.g. gemini-pro-agent) +const MODEL_PATTERNS = { + antigravity: [ + { match: /flash/i, alias: "gemini-3-flash" }, + { match: /pro.*low|low.*pro/i, alias: "gemini-3.1-pro-low" }, + { match: /gemini.*pro|pro.*gemini/i, alias: "gemini-3.1-pro-high" }, + { match: /opus/i, alias: "claude-opus-4-6-thinking" }, + { match: /sonnet|claude/i, alias: "claude-sonnet-4-6" }, + { match: /gpt.*oss|oss/i, alias: "gpt-oss-120b-medium" }, + ], +}; + // URL substrings whose request/response should NOT be dumped to file (telemetry, polling, empty) const LOG_BLACKLIST_URL_PARTS = [ "recordCodeAssistMetrics", @@ -38,4 +53,4 @@ function getToolForHost(host) { return null; } -module.exports = { TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, LOG_BLACKLIST_URL_PARTS, getToolForHost }; +module.exports = { IS_DEV, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, LOG_BLACKLIST_URL_PARTS, getToolForHost }; diff --git a/src/mitm/handlers/antigravity.js b/src/mitm/handlers/antigravity.js index a93f7a5..aa9273f 100644 --- a/src/mitm/handlers/antigravity.js +++ b/src/mitm/handlers/antigravity.js @@ -1,4 +1,5 @@ const { err, createResponseDumper } = require("../logger"); +const { IS_DEV } = require("../config"); const { fetchRouter, pipeSSE } = require("./base"); /** @@ -7,7 +8,7 @@ const { fetchRouter, pipeSSE } = require("./base"); * runs antigravity→openai→provider→openai→antigravity translators internally. */ async function intercept(req, res, bodyBuffer, mappedModel) { - const dumper = createResponseDumper(req, "intercept-antigravity"); + const dumper = IS_DEV ? createResponseDumper(req, "intercept-antigravity") : null; const isStream = req.url.includes(":streamGenerateContent"); try { const body = JSON.parse(bodyBuffer.toString()); diff --git a/src/mitm/handlers/kiro.js b/src/mitm/handlers/kiro.js index 1252c94..fa7b016 100644 --- a/src/mitm/handlers/kiro.js +++ b/src/mitm/handlers/kiro.js @@ -1,11 +1,13 @@ const { err } = require("../logger"); +const { IS_DEV } = require("../config"); const { fetchRouter } = require("./base"); const fs = require("fs"); const path = require("path"); -// Debug trace log — written to data/logs/mitm/kiro-debug.log +// Debug trace log — written to data/logs/mitm/kiro-debug.log (dev only) const DEBUG_LOG = path.join(__dirname, "../../../data/logs/mitm/kiro-debug.log"); function dbg(msg) { + if (!IS_DEV) return; try { fs.appendFileSync(DEBUG_LOG, `${new Date().toISOString()} ${msg}\n`); } catch {} diff --git a/src/mitm/logger.js b/src/mitm/logger.js index c1fd9b0..5e53210 100644 --- a/src/mitm/logger.js +++ b/src/mitm/logger.js @@ -14,6 +14,16 @@ const err = (msg) => console.error(`[${time()}] ❌ [MITM] ${msg}`); const DUMP_DIR = path.join(DATA_DIR, "logs", "mitm"); if (!fs.existsSync(DUMP_DIR)) fs.mkdirSync(DUMP_DIR, { recursive: true }); +// Clear all files inside DUMP_DIR (called on MITM server start to avoid unbounded growth) +function clearDumpDir() { + try { + if (!fs.existsSync(DUMP_DIR)) return; + for (const f of fs.readdirSync(DUMP_DIR)) { + try { fs.rmSync(path.join(DUMP_DIR, f), { recursive: true, force: true }); } catch { /* ignore */ } + } + } catch { /* ignore */ } +} + const EMPTY_BODY_RE = /^\s*(\{\s*\}|\[\s*\]|null)?\s*$/; function slugify(s, max = 80) { @@ -93,4 +103,4 @@ function createResponseDumper(req, tag = "raw") { }; } -module.exports = { log, err, dumpRequest, createResponseDumper }; +module.exports = { log, err, dumpRequest, createResponseDumper, clearDumpDir }; diff --git a/src/mitm/server.js b/src/mitm/server.js index fc4aacc..f4f76e3 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -4,14 +4,17 @@ const path = require("path"); const dns = require("dns"); const { promisify } = require("util"); const { execSync } = require("child_process"); -const { log, err, dumpRequest, createResponseDumper } = require("./logger"); -const { TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, getToolForHost } = require("./config"); +const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger"); +const { IS_DEV, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, getToolForHost } = require("./config"); const { DATA_DIR, MITM_DIR } = require("./paths"); const { getCertForDomain } = require("./cert/generate"); const { getMitmAlias } = require("./dbReader"); const LOCAL_PORT = 443; const IS_WIN = process.platform === "win32"; -const ENABLE_FILE_LOG = true; +const ENABLE_FILE_LOG = IS_DEV; + +// Clear stale dump files on every MITM start (prevents unbounded disk usage) +clearDumpDir(); const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; // Host rewrite for upstream forward: PROD cloudcode-pa is rate-limited (429), @@ -108,7 +111,13 @@ function getMappedModel(tool, model) { if (aliases[lookup]) return aliases[lookup]; // Prefix match fallback const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (lookup.startsWith(k) || k.startsWith(lookup))); - return prefixKey ? aliases[prefixKey] : null; + if (prefixKey) return aliases[prefixKey]; + // Pattern fallback: catches AG renamed variants (e.g. gemini-pro-agent → gemini-3.1-pro-high) + const patterns = MODEL_PATTERNS?.[tool] || []; + for (const { match, alias } of patterns) { + if (match.test(lookup) && aliases[alias]) return aliases[alias]; + } + return null; } catch { return null; } } diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 9306715..bbd6dce 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -8,8 +8,9 @@ export const MITM_TOOLS = { description: "Google Antigravity IDE with MITM", configType: "mitm", mitmDomain: "daily-cloudcode-pa.googleapis.com", - modelAliases: ["claude-opus-4-6-thinking", "claude-sonnet-4-6", "gemini-3-flash", "gpt-oss-120b-medium", "gemini-3-pro-high", "gemini-3-pro-low"], + modelAliases: ["claude-opus-4-6-thinking", "claude-sonnet-4-6", "gemini-3-flash", "gpt-oss-120b-medium", "gemini-3-pro-high", "gemini-3-pro-low", "gemini-pro-agent"], defaultModels: [ + { id: "gemini-pro-agent", name: "Gemini Pro Agent (AG v1.23+ Agent Mode)", alias: "gemini-pro-agent" }, { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", alias: "gemini-3.1-pro-high" }, { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", alias: "gemini-3.1-pro-low" }, { id: "gemini-3-flash", name: "Gemini 3 Flash / Default", alias: "gemini-3-flash" }, diff --git a/src/shared/utils/machineId.js b/src/shared/utils/machineId.js index 7e63241..68c6ae2 100644 --- a/src/shared/utils/machineId.js +++ b/src/shared/utils/machineId.js @@ -1,52 +1,40 @@ import { machineIdSync } from 'node-machine-id'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { DATA_DIR } from '@/lib/dataDir'; -/** - * Get consistent machine ID using node-machine-id with salt - * This ensures the same physical machine gets the same ID across runs - * - * @param {string} salt - Optional salt to use (defaults to environment variable) - * @returns {Promise} Machine ID (16-character base32) - */ -export async function getConsistentMachineId(salt = null) { - // For server-side, use node-machine-id with salt - const saltValue = salt || process.env.MACHINE_ID_SALT || 'endpoint-proxy-salt'; +const MACHINE_ID_FILE = path.join(DATA_DIR, 'machine-id'); +let cachedRawId = null; + +// Persist raw machine ID to file → guarantees CLI/server/middleware see same value +// even when machineIdSync fails or returns inconsistent values across runtimes. +function loadRawMachineId() { + if (cachedRawId) return cachedRawId; try { - const rawMachineId = machineIdSync(); - // Create consistent ID using salt - const crypto = await import('crypto'); - const hashedMachineId = crypto.createHash('sha256').update(rawMachineId + saltValue).digest('hex'); - // Return only first 16 characters for brevity - return hashedMachineId.substring(0, 16); - } catch (error) { - console.log('Error getting machine ID:', error); - // Fallback to random ID if node-machine-id fails - return crypto.randomUUID ? crypto.randomUUID() : - 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c == 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); + cachedRawId = fs.readFileSync(MACHINE_ID_FILE, 'utf8').trim(); + if (cachedRawId) return cachedRawId; + } catch {} + try { + cachedRawId = machineIdSync(); + } catch { + cachedRawId = crypto.randomUUID(); } + try { + fs.mkdirSync(DATA_DIR, { recursive: true }); + fs.writeFileSync(MACHINE_ID_FILE, cachedRawId, { mode: 0o600 }); + } catch {} + return cachedRawId; +} + +export async function getConsistentMachineId(salt = null) { + const saltValue = salt || process.env.MACHINE_ID_SALT || 'endpoint-proxy-salt'; + const raw = loadRawMachineId(); + return crypto.createHash('sha256').update(raw + saltValue).digest('hex').substring(0, 16); } -/** - * Get raw machine ID without hashing (for debugging purposes) - * @returns {Promise} Raw machine ID - */ export async function getRawMachineId() { - // For server-side, use raw node-machine-id - try { - return machineIdSync(); - } catch (error) { - console.log('Error getting raw machine ID:', error); - // Fallback to random ID if node-machine-id fails - return crypto.randomUUID ? crypto.randomUUID() : - 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c == 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - } + return loadRawMachineId(); } /**