7 ? provider.color : provider.color + "15"}` }}
>
{imgError ? (
@@ -501,7 +497,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
{}}
+ onChange={() => { }}
title={allDisabled ? "Enable provider" : "Disable provider"}
/>
@@ -561,7 +557,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
7 ? provider.color : provider.color + "15"}` }}
>
{imgError ? (
@@ -621,7 +617,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
{}}
+ onChange={() => { }}
title={allDisabled ? "Enable provider" : "Disable provider"}
/>
@@ -955,9 +951,8 @@ function ProviderTestResultsView({ results }) {
{r.latencyMs}ms
)}
{r.valid ? "OK" : r.diagnosis?.type || "ERROR"}
diff --git a/src/app/(dashboard)/dashboard/translator/page.js b/src/app/(dashboard)/dashboard/translator/page.js
index 999c6db..0e44e1b 100644
--- a/src/app/(dashboard)/dashboard/translator/page.js
+++ b/src/app/(dashboard)/dashboard/translator/page.js
@@ -180,8 +180,11 @@ export default function TranslatorPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ file: "5_res_provider.txt", content: full })
});
- } catch (e) { alert(e.message); }
- setLoad("send", false);
+ } catch (e) {
+ alert(e.message);
+ } finally {
+ setLoad("send", false);
+ }
};
const handleCopy = async (id) => {
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
index c9b5d12..5dfe61e 100644
--- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
@@ -247,22 +247,11 @@ export default function ProviderLimits() {
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
);
- // Sort providers: antigravity first, then kiro, then others alphabetically
+ // Sort providers by USAGE_SUPPORTED_PROVIDERS order, then alphabetically
const sortedConnections = [...filteredConnections].sort((a, b) => {
- const getProviderPriority = (provider) => {
- if (provider === "antigravity") return 1;
- if (provider === "kiro") return 2;
- return 3;
- };
-
- const priorityA = getProviderPriority(a.provider);
- const priorityB = getProviderPriority(b.provider);
-
- if (priorityA !== priorityB) {
- return priorityA - priorityB;
- }
-
- // Same priority: sort alphabetically
+ const orderA = USAGE_SUPPORTED_PROVIDERS.indexOf(a.provider);
+ const orderB = USAGE_SUPPORTED_PROVIDERS.indexOf(b.provider);
+ if (orderA !== orderB) return orderA - orderB;
return a.provider.localeCompare(b.provider);
});
diff --git a/src/app/api/cli-tools/codex-settings/route.js b/src/app/api/cli-tools/codex-settings/route.js
index 76ec7c6..b2ec6be 100644
--- a/src/app/api/cli-tools/codex-settings/route.js
+++ b/src/app/api/cli-tools/codex-settings/route.js
@@ -6,6 +6,7 @@ import { promisify } from "util";
import fs from "fs/promises";
import path from "path";
import os from "os";
+import { parseTOML, stringifyTOML } from "confbox";
const execAsync = promisify(exec);
@@ -13,62 +14,31 @@ const getCodexDir = () => path.join(os.homedir(), ".codex");
const getCodexConfigPath = () => path.join(getCodexDir(), "config.toml");
const getCodexAuthPath = () => path.join(getCodexDir(), "auth.json");
-// Parse TOML config to object (simple parser for codex config)
-const parseToml = (content) => {
- const result = { _root: {}, _sections: {} };
- let currentSection = "_root";
-
- content.split("\n").forEach((line) => {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith("#")) return;
-
- // Section header like [model_providers.9router]
- const sectionMatch = trimmed.match(/^\[(.+)\]$/);
- if (sectionMatch) {
- currentSection = sectionMatch[1];
- result._sections[currentSection] = {};
- return;
+// Flatten confbox-parsed TOML into a writable object, preserving nested tables
+const parsedToWritable = (obj) => obj ?? {};
+
+// Set a nested key from a flat dotted path, creating intermediate objects as needed
+const setNestedSection = (obj, dottedKey, value) => {
+ const keys = dottedKey.split(".");
+ let cur = obj;
+ for (let i = 0; i < keys.length - 1; i++) {
+ if (cur[keys[i]] == null || typeof cur[keys[i]] !== "object") {
+ cur[keys[i]] = {};
}
-
- // Key = value
- const kvMatch = trimmed.match(/^([^=]+)\s*=\s*(.+)$/);
- if (kvMatch) {
- const key = kvMatch[1].trim();
- let value = kvMatch[2].trim();
- // Remove quotes
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
- value = value.slice(1, -1);
- }
- if (currentSection === "_root") {
- result._root[key] = value;
- } else {
- result._sections[currentSection][key] = value;
- }
- }
- });
-
- return result;
+ cur = cur[keys[i]];
+ }
+ cur[keys[keys.length - 1]] = value;
};
-// Convert parsed object back to TOML string
-const toToml = (parsed) => {
- let lines = [];
-
- // Root level keys
- Object.entries(parsed._root).forEach(([key, value]) => {
- lines.push(`${key} = "${value}"`);
- });
-
- // Sections
- Object.entries(parsed._sections).forEach(([section, values]) => {
- lines.push("");
- lines.push(`[${section}]`);
- Object.entries(values).forEach(([key, value]) => {
- lines.push(`${key} = "${value}"`);
- });
- });
-
- return lines.join("\n") + "\n";
+// Delete a nested key from a flat dotted path
+const deleteNestedSection = (obj, dottedKey) => {
+ const keys = dottedKey.split(".");
+ let cur = obj;
+ for (let i = 0; i < keys.length - 1; i++) {
+ cur = cur?.[keys[i]];
+ if (cur == null) return;
+ }
+ delete cur[keys[keys.length - 1]];
};
// Check if codex CLI is installed
@@ -144,27 +114,27 @@ export async function POST(request) {
await fs.mkdir(codexDir, { recursive: true });
// Read and parse existing config
- let parsed = { _root: {}, _sections: {} };
+ let parsed = {};
try {
const existingConfig = await fs.readFile(configPath, "utf-8");
- parsed = parseToml(existingConfig);
+ parsed = parsedToWritable(parseTOML(existingConfig));
} catch { /* No existing config */ }
// Update only 9Router related fields (api_key goes to auth.json, not config.toml)
- parsed._root.model = model;
- parsed._root.model_provider = "9router";
-
+ parsed.model = model;
+ parsed.model_provider = "9router";
+
// Update or create 9router provider section (no api_key - Codex reads from auth.json)
// Ensure /v1 suffix is added only once
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
- parsed._sections["model_providers.9router"] = {
+ setNestedSection(parsed, "model_providers.9router", {
name: "9Router",
base_url: normalizedBaseUrl,
wire_api: "responses",
- };
+ });
// Write merged config
- const configContent = toToml(parsed);
+ const configContent = stringifyTOML(parsed);
await fs.writeFile(configPath, configContent);
// Update auth.json with OPENAI_API_KEY (Codex reads this first)
@@ -195,10 +165,10 @@ export async function DELETE() {
const configPath = getCodexConfigPath();
// Read and parse existing config
- let parsed = { _root: {}, _sections: {} };
+ let parsed = {};
try {
const existingConfig = await fs.readFile(configPath, "utf-8");
- parsed = parseToml(existingConfig);
+ parsed = parsedToWritable(parseTOML(existingConfig));
} catch (error) {
if (error.code === "ENOENT") {
return NextResponse.json({
@@ -210,16 +180,16 @@ export async function DELETE() {
}
// Remove 9Router related root fields only if they point to 9router
- if (parsed._root.model_provider === "9router") {
- delete parsed._root.model;
- delete parsed._root.model_provider;
+ if (parsed.model_provider === "9router") {
+ delete parsed.model;
+ delete parsed.model_provider;
}
-
+
// Remove 9router provider section
- delete parsed._sections["model_providers.9router"];
+ deleteNestedSection(parsed, "model_providers.9router");
// Write updated config
- const configContent = toToml(parsed);
+ const configContent = stringifyTOML(parsed);
await fs.writeFile(configPath, configContent);
// Remove OPENAI_API_KEY from auth.json
diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js
index e5fbda6..a14beb1 100644
--- a/src/app/api/providers/[id]/models/route.js
+++ b/src/app/api/providers/[id]/models/route.js
@@ -3,7 +3,7 @@ import { getProviderConnectionById } from "@/models";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { KiroService } from "@/lib/oauth/services/kiro";
import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
-import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
+import { refreshGoogleToken, updateProviderCredentials, refreshKiroToken } from "@/sse/services/tokenRefresh";
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
@@ -162,6 +162,8 @@ const PROVIDER_MODELS_CONFIG = {
nebius: createOpenAIModelsConfig("https://api.studio.nebius.ai/v1/models"),
siliconflow: createOpenAIModelsConfig("https://api.siliconflow.cn/v1/models"),
hyperbolic: createOpenAIModelsConfig("https://api.hyperbolic.xyz/v1/models"),
+ ollama: createOpenAIModelsConfig("https://ollama.com/api/tags"),
+ "ollama-local": createOpenAIModelsConfig("http://localhost:11434/api/tags"),
nanobanana: createOpenAIModelsConfig("https://api.nanobananaapi.ai/v1/models"),
chutes: createOpenAIModelsConfig("https://llm.chutes.ai/v1/models"),
nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"),
@@ -256,22 +258,56 @@ export async function GET(request, { params }) {
// Kiro: Try dynamic model fetching first
if (connection.provider === "kiro") {
+ let warning;
try {
const kiroService = new KiroService();
const profileArn = connection.providerSpecificData?.profileArn;
const accessToken = connection.accessToken;
+ const refreshToken = connection.refreshToken;
if (accessToken && profileArn) {
- const models = await kiroService.listAvailableModels(accessToken, profileArn);
- return NextResponse.json({
- provider: connection.provider,
- connectionId: connection.id,
- models
- });
+ try {
+ const models = await kiroService.listAvailableModels(accessToken, profileArn);
+ return NextResponse.json({
+ provider: connection.provider,
+ connectionId: connection.id,
+ models
+ });
+ } catch (error) {
+ if (error.message.includes("AccessDeniedException") && refreshToken) {
+ console.log("Kiro token invalid/expired. Attempting refresh...");
+ const refreshed = await refreshKiroToken(refreshToken, connection.providerSpecificData);
+
+ if (refreshed?.accessToken) {
+ await updateProviderCredentials(connection.id, {
+ accessToken: refreshed.accessToken,
+ refreshToken: refreshed.refreshToken || refreshToken,
+ expiresIn: refreshed.expiresIn,
+ });
+
+ const models = await kiroService.listAvailableModels(refreshed.accessToken, profileArn);
+ return NextResponse.json({
+ provider: connection.provider,
+ connectionId: connection.id,
+ models
+ });
+ }
+ }
+ throw error; // Let outer catch handle it
+ }
}
} catch (error) {
+ warning = `Failed to fetch Kiro models: ${error.message}`;
console.log("Failed to fetch Kiro models dynamically, falling back to static:", error.message);
}
+
+ // Return empty dynamic list so UI falls back to static provider models.
+ return NextResponse.json({
+ provider: connection.provider,
+ connectionId: connection.id,
+ models: [],
+ warning,
+ });
}
if (connection.provider === "gemini-cli") {
diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js
index 7a11f97..1c7f19d 100644
--- a/src/app/api/providers/[id]/test/testUtils.js
+++ b/src/app/api/providers/[id]/test/testUtils.js
@@ -48,6 +48,7 @@ const OAUTH_TEST_CONFIG = {
},
qwen: { checkExpiry: true, refreshable: true },
kiro: { checkExpiry: true, refreshable: true },
+ "kimi-coding": { checkExpiry: true, refreshable: false },
cursor: { tokenExists: true },
kilocode: {
url: `${KILOCODE_CONFIG.apiBaseUrl}/api/profile`,
@@ -459,6 +460,15 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
const res = await fetchWithConnectionProxy("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
+ case "ollama": {
+ const res = await fetch("https://ollama.com/api/tags", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "ollama-local": {
+ // No auth required for local Ollama
+ const res = await fetch("http://localhost:11434/api/tags");
+ return { valid: res.ok, error: res.ok ? null : "Ollama not running on localhost:11434" };
+ }
case "deepgram": {
const res = await fetchWithConnectionProxy("https://api.deepgram.com/v1/projects", { headers: { Authorization: `Token ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js
index e4f16c9..f1cc3df 100644
--- a/src/app/api/providers/validate/route.js
+++ b/src/app/api/providers/validate/route.js
@@ -163,6 +163,8 @@ export async function POST(request) {
case "nebius":
case "siliconflow":
case "hyperbolic":
+ case "ollama":
+ case "ollama-local":
case "assemblyai":
case "nanobanana":
case "chutes":
@@ -180,6 +182,8 @@ export async function POST(request) {
nebius: "https://api.studio.nebius.ai/v1/models",
siliconflow: "https://api.siliconflow.cn/v1/models",
hyperbolic: "https://api.hyperbolic.xyz/v1/models",
+ ollama: "https://ollama.com/api/tags",
+ "ollama-local": "http://localhost:11434/api/tags",
assemblyai: "https://api.assemblyai.com/v1/account",
nanobanana: "https://api.nanobananaapi.ai/v1/models",
chutes: "https://llm.chutes.ai/v1/models",
@@ -200,6 +204,38 @@ export async function POST(request) {
break;
}
+ case "vertex": {
+ // Raw key: probe global endpoint (always 404 for unknown model, never 401)
+ // SA JSON: attempt token mint via JWT assertion
+ const saJson = (() => { try { const p = JSON.parse(apiKey); return p.type === "service_account" ? p : null; } catch { return null; } })();
+ if (saJson) {
+ // Validate SA JSON has required fields
+ isValid = !!(saJson.client_email && saJson.private_key && saJson.project_id);
+ } else {
+ // Raw key: probe Vertex — 404 means key is valid (model just doesn't exist), 401 means invalid key
+ const probeRes = await fetch(
+ `https://aiplatform.googleapis.com/v1/publishers/google/models/__probe__:generateContent?key=${apiKey}`,
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }
+ );
+ isValid = probeRes.status !== 401 && probeRes.status !== 403;
+ }
+ break;
+ }
+
+ case "vertex-partner": {
+ const saJson = (() => { try { const p = JSON.parse(apiKey); return p.type === "service_account" ? p : null; } catch { return null; } })();
+ if (saJson) {
+ isValid = !!(saJson.client_email && saJson.private_key && saJson.project_id);
+ } else {
+ const probeRes = await fetch(
+ `https://aiplatform.googleapis.com/v1/publishers/google/models/__probe__:generateContent?key=${apiKey}`,
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }
+ );
+ isValid = probeRes.status !== 401 && probeRes.status !== 403;
+ }
+ break;
+ }
+
default:
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
}
diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js
index b0830af..adf6138 100644
--- a/src/app/api/usage/[connectionId]/route.js
+++ b/src/app/api/usage/[connectionId]/route.js
@@ -6,7 +6,7 @@ import { getUsageForProvider } from "open-sse/services/usage.js";
import { getExecutor } from "open-sse/executors/index.js";
/**
* Refresh credentials using executor and update database
- * @returns {{ connection, refreshed: boolean }}
+ * @returns Promise<{ connection, refreshed: boolean }>
*/
async function refreshAndUpdateCredentials(connection) {
const executor = getExecutor(connection.provider);
@@ -91,11 +91,12 @@ async function refreshAndUpdateCredentials(connection) {
* GET /api/usage/[connectionId] - Get usage data for a specific connection
*/
export async function GET(request, { params }) {
+ let connection;
try {
const { connectionId } = await params;
// Get connection from database
- let connection = await getProviderConnectionById(connectionId);
+ connection = await getProviderConnectionById(connectionId);
if (!connection) {
return Response.json({ error: "Connection not found" }, { status: 404 });
}
@@ -120,8 +121,7 @@ export async function GET(request, { params }) {
const usage = await getUsageForProvider(connection);
return Response.json(usage);
} catch (error) {
- console.error("[Usage API] Error fetching usage:", error);
- console.error("[Usage API] Error stack:", error.stack);
+ console.warn(`[Usage] ${connection?.provider}: ${error.message}`);
return Response.json({ error: error.message }, { status: 500 });
}
}
diff --git a/src/app/api/usage/stream/route.js b/src/app/api/usage/stream/route.js
index acfad38..8fa20ac 100644
--- a/src/app/api/usage/stream/route.js
+++ b/src/app/api/usage/stream/route.js
@@ -39,7 +39,9 @@ export async function GET() {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
} catch {
state.closed = true;
+ statsEmitter.off("update", state.send);
statsEmitter.off("pending", state.sendPending);
+ clearInterval(state.keepalive);
}
};
diff --git a/src/lib/localDb.js b/src/lib/localDb.js
index cad6c9e..d7e9a1b 100644
--- a/src/lib/localDb.js
+++ b/src/lib/localDb.js
@@ -53,6 +53,7 @@ const defaultData = {
tunnelEnabled: false,
tunnelUrl: "",
stickyRoundRobinLimit: 3,
+ providerStrategies: {},
requireLogin: true,
observabilityEnabled: true,
observabilityMaxRecords: 1000,
@@ -80,6 +81,7 @@ function cloneDefaultData() {
tunnelEnabled: false,
tunnelUrl: "",
stickyRoundRobinLimit: 3,
+ providerStrategies: {},
requireLogin: true,
observabilityEnabled: true,
observabilityMaxRecords: 1000,
diff --git a/src/lib/requestDetailsDb.js b/src/lib/requestDetailsDb.js
index 6bd8135..8f8a904 100644
--- a/src/lib/requestDetailsDb.js
+++ b/src/lib/requestDetailsDb.js
@@ -246,23 +246,25 @@ export async function getRequestDetailById(id) {
return db.data.records.find(r => r.id === id) || null;
}
-// Graceful shutdown
-let shutdownHandlerRegistered = false;
+// Graceful shutdown — use named handler so we can remove it on re-registration
+const _shutdownHandler = async () => {
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
+ if (writeBuffer.length > 0) await flushToDatabase();
+};
function ensureShutdownHandler() {
- if (shutdownHandlerRegistered || isCloud) return;
+ if (isCloud) return;
- const handler = async () => {
- if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
- if (writeBuffer.length > 0) await flushToDatabase();
- };
+ // Remove any previously registered listeners from this module (hot-reload safety)
+ process.off("beforeExit", _shutdownHandler);
+ process.off("SIGINT", _shutdownHandler);
+ process.off("SIGTERM", _shutdownHandler);
+ process.off("exit", _shutdownHandler);
- process.on("beforeExit", handler);
- process.on("SIGINT", handler);
- process.on("SIGTERM", handler);
- process.on("exit", handler);
-
- shutdownHandlerRegistered = true;
+ process.on("beforeExit", _shutdownHandler);
+ process.on("SIGINT", _shutdownHandler);
+ process.on("SIGTERM", _shutdownHandler);
+ process.on("exit", _shutdownHandler);
}
ensureShutdownHandler();
diff --git a/src/lib/tunnel/cloudflared.js b/src/lib/tunnel/cloudflared.js
index 555b5fb..2188521 100644
--- a/src/lib/tunnel/cloudflared.js
+++ b/src/lib/tunnel/cloudflared.js
@@ -141,8 +141,10 @@ export async function spawnCloudflared(tunnelToken) {
const handleLog = (data) => {
const msg = data.toString();
- if (msg.includes("Registered tunnel connection")) {
- connectionCount++;
+ // Count exact occurrences in this chunk (each chunk may contain multiple lines)
+ const matches = msg.match(/Registered tunnel connection/g);
+ if (matches) {
+ connectionCount += matches.length;
if (connectionCount >= 4 && !resolved) {
resolved = true;
clearTimeout(timeout);
@@ -165,6 +167,7 @@ export async function spawnCloudflared(tunnelToken) {
child.on("exit", (code) => {
cloudflaredProcess = null;
clearPid();
+ const wasConnected = resolved; // true = already connected successfully
if (!resolved) {
resolved = true;
clearTimeout(timeout);
@@ -173,8 +176,8 @@ export async function spawnCloudflared(tunnelToken) {
return;
}
}
- // Notify reconnect handler if tunnel died after successful connection
- if (unexpectedExitHandler) {
+ // Only notify on unexpected exit AFTER successful connection
+ if (wasConnected && unexpectedExitHandler) {
unexpectedExitHandler();
}
});
diff --git a/src/lib/tunnel/tunnelManager.js b/src/lib/tunnel/tunnelManager.js
index a3ff4c1..14026df 100644
--- a/src/lib/tunnel/tunnelManager.js
+++ b/src/lib/tunnel/tunnelManager.js
@@ -8,7 +8,8 @@ const MACHINE_ID_SALT = "9router-tunnel-salt";
const API_KEY_SECRET = "9router-tunnel-api-key-secret";
const SHORT_ID_LENGTH = 6;
const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
-const RECONNECT_DELAYS_MS = [5000, 15000, 30000];
+const RECONNECT_DELAYS_MS = [5000, 10000, 20000, 30000, 60000];
+const MAX_RECONNECT_ATTEMPTS = RECONNECT_DELAYS_MS.length;
let isReconnecting = false;
@@ -83,8 +84,10 @@ export async function enableTunnel() {
await updateSettings({ tunnelEnabled: true, tunnelUrl: hostname });
- // Register exit handler for auto-reconnect on unexpected crash/sleep-wake
- setUnexpectedExitHandler(() => scheduleReconnect(0));
+ // Re-register exit handler each time tunnel starts (handles reconnect scenario too)
+ setUnexpectedExitHandler(() => {
+ if (!isReconnecting) scheduleReconnect(0);
+ });
return { success: true, tunnelUrl: hostname, shortId };
}
@@ -112,7 +115,7 @@ async function scheduleReconnect(attempt) {
console.log(`[Tunnel] Reconnect attempt ${attempt + 1} failed:`, err.message);
isReconnecting = false;
const nextAttempt = attempt + 1;
- if (nextAttempt < RECONNECT_DELAYS_MS.length) {
+ if (nextAttempt < MAX_RECONNECT_ATTEMPTS) {
scheduleReconnect(nextAttempt);
} else {
console.log("[Tunnel] All reconnect attempts exhausted");
diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js
index e81513e..ded59b5 100644
--- a/src/lib/usageDb.js
+++ b/src/lib/usageDb.js
@@ -245,8 +245,11 @@ export async function saveRequestUsage(entry) {
entry.cost = entryCost;
db.data.history.push(entry);
- // Optional: Limit history size if needed in future
- // if (db.data.history.length > 10000) db.data.history.shift();
+ // Cap history to prevent unbounded memory/disk growth
+ const MAX_HISTORY = 10000;
+ if (db.data.history.length > MAX_HISTORY) {
+ db.data.history.splice(0, db.data.history.length - MAX_HISTORY);
+ }
await db.write();
statsEmitter.emit("update");
diff --git a/src/mitm/manager.js b/src/mitm/manager.js
index a265f46..89ee0bc 100644
--- a/src/mitm/manager.js
+++ b/src/mitm/manager.js
@@ -16,6 +16,14 @@ const MITM_PORT = 443;
const MITM_WIN_NODE_PORT = 8443;
const PID_FILE = path.join(MITM_DIR, ".mitm.pid");
+const MITM_MAX_RESTARTS = 5;
+const MITM_RESTART_DELAYS_MS = [5000, 10000, 20000, 30000, 60000];
+const MITM_RESTART_RESET_MS = 60000;
+
+let mitmRestartCount = 0;
+let mitmLastStartTime = 0;
+let mitmIsRestarting = false;
+
function resolveServerPath() {
if (process.env.MITM_SERVER_PATH) return process.env.MITM_SERVER_PATH;
const sibling = path.join(__dirname, "server.js");
@@ -273,6 +281,50 @@ async function getMitmStatus() {
return { running, pid, certExists, dnsStatus };
}
+async function scheduleMitmRestart(apiKey) {
+ if (mitmIsRestarting) return;
+
+ const aliveMs = Date.now() - mitmLastStartTime;
+ if (aliveMs >= MITM_RESTART_RESET_MS) mitmRestartCount = 0;
+
+ if (mitmRestartCount >= MITM_MAX_RESTARTS) {
+ console.error("[MITM] Max restart attempts reached. Giving up.");
+ return;
+ }
+
+ const attempt = mitmRestartCount;
+ const delay = MITM_RESTART_DELAYS_MS[Math.min(attempt, MITM_RESTART_DELAYS_MS.length - 1)];
+ mitmRestartCount++;
+ mitmIsRestarting = true;
+
+ console.log(`[MITM] Restarting in ${delay / 1000}s... (${mitmRestartCount}/${MITM_MAX_RESTARTS})`);
+ await new Promise((r) => setTimeout(r, delay));
+
+ try {
+ const settings = _getSettings ? await _getSettings() : null;
+ if (settings && !settings.mitmEnabled) {
+ console.log("[MITM] MITM disabled, skipping restart");
+ mitmIsRestarting = false;
+ return;
+ }
+ const password = getCachedPassword() || await loadEncryptedPassword();
+ if (!password && !IS_WIN) {
+ console.error("[MITM] No cached password, cannot auto-restart");
+ mitmIsRestarting = false;
+ return;
+ }
+ await startServer(apiKey, password);
+ console.log("[MITM] Restarted successfully");
+ mitmRestartCount = 0;
+ mitmIsRestarting = false;
+ } catch (err) {
+ console.error(`[MITM] Restart attempt ${mitmRestartCount}/${MITM_MAX_RESTARTS} failed:`, err.message);
+ mitmIsRestarting = false;
+ // Schedule next retry
+ scheduleMitmRestart(apiKey);
+ }
+}
+
/**
* Start MITM server only (cert + server, no DNS)
*/
@@ -378,6 +430,7 @@ async function startServer(apiKey, sudoPassword) {
if (!IS_WIN && serverProcess) {
serverPid = serverProcess.pid;
fs.writeFileSync(PID_FILE, String(serverPid));
+ mitmLastStartTime = Date.now();
}
let startError = null;
@@ -397,6 +450,8 @@ async function startServer(apiKey, sudoPassword) {
serverProcess = null;
serverPid = null;
try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
+ // Auto-restart on unexpected exit
+ if (code !== 0 && !mitmIsRestarting) scheduleMitmRestart(apiKey);
});
}
@@ -425,6 +480,9 @@ async function startServer(apiKey, sudoPassword) {
* Stop MITM server — removes ALL tool DNS entries first, then kills server
*/
async function stopServer(sudoPassword) {
+ // Prevent auto-restart from triggering on intentional stop
+ mitmIsRestarting = true;
+ mitmRestartCount = 0;
console.log("[MITM] Stopping server...");
// Kill server process
@@ -476,6 +534,7 @@ async function stopServer(sudoPassword) {
try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
await saveMitmSettings(false, null);
+ mitmIsRestarting = false;
return { running: false, pid: null };
}
diff --git a/src/mitm/server.js b/src/mitm/server.js
index 735cbe2..2fe2757 100644
--- a/src/mitm/server.js
+++ b/src/mitm/server.js
@@ -85,7 +85,7 @@ const ANTIGRAVITY_URL_PATTERNS = [":generateContent", ":streamGenerateContent"];
// Copilot: OpenAI-compatible + Anthropic endpoints
const COPILOT_URL_PATTERNS = ["/chat/completions", "/v1/messages", "/responses"];
-const LOG_DIR = path.join(__dirname, "../../logs/mitm");
+const LOG_DIR = path.join(DATA_DIR, "logs", "mitm");
if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
function saveRequestLog(url, bodyBuffer) {
diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js
index 3640109..9e5218e 100644
--- a/src/shared/components/ModelSelectModal.js
+++ b/src/shared/components/ModelSelectModal.js
@@ -139,17 +139,32 @@ export default function ModelSelectModal({
hasModels: nodeModels.length > 0,
};
} else {
- const models = getModelsByProviderId(providerId);
- if (models.length > 0) {
+ const hardcodedModels = getModelsByProviderId(providerId);
+ const hardcodedIds = new Set(hardcodedModels.map((m) => m.id));
+
+ // Custom models user added via "Add Model" button (alias === modelId pattern)
+ const customModels = Object.entries(modelAliases)
+ .filter(([aliasName, fullModel]) =>
+ fullModel.startsWith(`${alias}/`) &&
+ aliasName === fullModel.replace(`${alias}/`, "") &&
+ !hardcodedIds.has(fullModel.replace(`${alias}/`, ""))
+ )
+ .map(([, fullModel]) => {
+ const modelId = fullModel.replace(`${alias}/`, "");
+ return { id: modelId, name: modelId, value: fullModel, isCustom: true };
+ });
+
+ const allModels = [
+ ...hardcodedModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}` })),
+ ...customModels,
+ ];
+
+ if (allModels.length > 0) {
groups[providerId] = {
name: providerInfo.name,
alias: alias,
color: providerInfo.color,
- models: models.map((m) => ({
- id: m.id,
- name: m.name,
- value: `${alias}/${m.id}`,
- })),
+ models: allModels,
};
}
}
@@ -299,6 +314,11 @@ export default function ModelSelectModal({
edit
{model.name}
+ ) : model.isCustom ? (
+
+ {model.name}
+ custom
+
) : model.name}
);
diff --git a/src/shared/components/Tooltip.js b/src/shared/components/Tooltip.js
new file mode 100644
index 0000000..b7d0c0c
--- /dev/null
+++ b/src/shared/components/Tooltip.js
@@ -0,0 +1,19 @@
+"use client";
+
+export default function Tooltip({ text, children, position = "top" }) {
+ const posClass = {
+ top: "bottom-full left-1/2 -translate-x-1/2 mb-1.5",
+ bottom: "top-full left-1/2 -translate-x-1/2 mt-1.5",
+ left: "right-full top-1/2 -translate-y-1/2 mr-1.5",
+ right: "left-full top-1/2 -translate-y-1/2 ml-1.5",
+ }[position];
+
+ return (
+
+ {children}
+
+ {text}
+
+
+ );
+}
diff --git a/src/shared/components/index.js b/src/shared/components/index.js
index 56b3325..65f2bbc 100644
--- a/src/shared/components/index.js
+++ b/src/shared/components/index.js
@@ -25,6 +25,7 @@ export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as IFlowCookieModal } from "./IFlowCookieModal";
export { default as SegmentedControl } from "./SegmentedControl";
+export { default as Tooltip } from "./Tooltip";
// Layouts
export * from "./layouts";
diff --git a/src/shared/constants/config.js b/src/shared/constants/config.js
index a680cf8..53a3e53 100644
--- a/src/shared/constants/config.js
+++ b/src/shared/constants/config.js
@@ -47,6 +47,8 @@ export const PROVIDER_ENDPOINTS = {
openai: "https://api.openai.com/v1/chat/completions",
anthropic: "https://api.anthropic.com/v1/messages",
gemini: "https://generativelanguage.googleapis.com/v1beta/models",
+ ollama: "https://ollama.com/api/chat",
+ "ollama-local": "http://localhost:11434/api/chat",
};
// Re-export from providers.js for backward compatibility
diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js
index b5ba86d..f1dc0ee 100644
--- a/src/shared/constants/providers.js
+++ b/src/shared/constants/providers.js
@@ -15,6 +15,7 @@ export const OAUTH_PROVIDERS = {
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6" },
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" },
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" },
+ // "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" },
kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC" },
cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL" },
};
@@ -47,7 +48,11 @@ export const APIKEY_PROVIDERS = {
deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com" },
assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com" },
nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai" },
- chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#5B6EF5", textIcon: "CH", website: "https://chutes.ai" },
+ chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai" },
+ ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
+ "ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
+ vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai" },
+ "vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models" },
};
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
@@ -105,4 +110,11 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
}, {});
// Providers that support usage/quota API
-export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex"];
+export const USAGE_SUPPORTED_PROVIDERS = [
+ "claude",
+ "antigravity",
+ "kiro",
+ "github",
+ "codex",
+ "kimi-coding",
+];
diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js
index 04eb51a..b8b3eb9 100644
--- a/src/sse/handlers/chat.js
+++ b/src/sse/handlers/chat.js
@@ -12,7 +12,7 @@ import { getModelInfo, getComboModels } from "../services/model.js";
import { handleChatCore } from "open-sse/handlers/chatCore.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { handleComboChat } from "open-sse/services/combo.js";
-import { HTTP_STATUS } from "open-sse/config/constants.js";
+import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import { detectFormatByEndpoint } from "open-sse/translator/formats.js";
import * as log from "../utils/logger.js";
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
@@ -111,7 +111,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
return handleComboChat({
body,
models: comboModels,
- handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, forceSourceFormat),
+ handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
log
});
}
@@ -132,12 +132,12 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
const userAgent = request?.headers?.get("user-agent") || "";
// Try with available accounts (fallback on errors)
- let excludeConnectionId = null;
+ const excludeConnectionIds = new Set();
let lastError = null;
let lastStatus = null;
while (true) {
- const credentials = await getProviderCredentials(provider, excludeConnectionId, model);
+ const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
// All accounts unavailable
if (!credentials || credentials.allRateLimited) {
@@ -147,7 +147,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
}
- if (!excludeConnectionId) {
+ if (excludeConnectionIds.size === 0) {
log.error("AUTH", `No credentials for provider: ${provider}`);
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
@@ -156,8 +156,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
}
// Log account selection
- const accountId = credentials.connectionId.slice(0, 8);
- log.info("AUTH", `Using ${provider} account: ${accountId}...`);
+ log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
@@ -172,6 +171,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
}
// Use shared chatCore
+ const chatSettings = await getSettings();
const result = await handleChatCore({
body: { ...body, model: `${provider}/${model}` },
modelInfo: { provider, model },
@@ -181,6 +181,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
connectionId: credentials.connectionId,
userAgent,
apiKey,
+ ccFilterNaming: !!chatSettings.ccFilterNaming,
// Detect source format by endpoint + body
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
onCredentialsRefreshed: async (newCreds) => {
@@ -202,8 +203,8 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
if (shouldFallback) {
- log.warn("AUTH", `Account ${accountId}... unavailable (${result.status}), trying fallback`);
- excludeConnectionId = credentials.connectionId;
+ log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
+ excludeConnectionIds.add(credentials.connectionId);
lastError = result.error;
lastStatus = result.status;
continue;
diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js
index a8a68e0..4938094 100644
--- a/src/sse/handlers/embeddings.js
+++ b/src/sse/handlers/embeddings.js
@@ -9,7 +9,7 @@ import { getSettings } from "@/lib/localDb";
import { getModelInfo } from "../services/model.js";
import { handleEmbeddingsCore } from "open-sse/handlers/embeddingsCore.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
-import { HTTP_STATUS } from "open-sse/config/constants.js";
+import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import * as log from "../utils/logger.js";
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
@@ -80,12 +80,12 @@ export async function handleEmbeddings(request) {
}
// Credential + fallback loop (mirrors handleChat)
- let excludeConnectionId = null;
+ const excludeConnectionIds = new Set();
let lastError = null;
let lastStatus = null;
while (true) {
- const credentials = await getProviderCredentials(provider, excludeConnectionId, model);
+ const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
// All accounts unavailable
if (!credentials || credentials.allRateLimited) {
@@ -95,7 +95,7 @@ export async function handleEmbeddings(request) {
log.warn("EMBEDDINGS", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
}
- if (!excludeConnectionId) {
+ if (excludeConnectionIds.size === 0) {
log.error("AUTH", `No credentials for provider: ${provider}`);
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
@@ -103,8 +103,7 @@ export async function handleEmbeddings(request) {
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
}
- const accountId = credentials.connectionId.slice(0, 8);
- log.info("AUTH", `Using ${provider} account: ${accountId}...`);
+ log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
@@ -131,8 +130,8 @@ export async function handleEmbeddings(request) {
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
if (shouldFallback) {
- log.warn("AUTH", `Account ${accountId}... unavailable (${result.status}), trying fallback`);
- excludeConnectionId = credentials.connectionId;
+ log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
+ excludeConnectionIds.add(credentials.connectionId);
lastError = result.error;
lastStatus = result.status;
continue;
diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js
index 3daf41b..15e055c 100644
--- a/src/sse/services/auth.js
+++ b/src/sse/services/auth.js
@@ -11,10 +11,14 @@ let selectionMutex = Promise.resolve();
* Get provider credentials from localDb
* Filters out unavailable accounts and returns the selected account based on strategy
* @param {string} provider - Provider name
- * @param {string|null} excludeConnectionId - Connection ID to exclude (for retry with next account)
+ * @param {Set
|string|null} excludeConnectionIds - Connection ID(s) to exclude (for retry with next account)
* @param {string|null} model - Model name for per-model rate limit filtering
*/
-export async function getProviderCredentials(provider, excludeConnectionId = null, model = null) {
+export async function getProviderCredentials(provider, excludeConnectionIds = null, model = null) {
+ // Normalize to Set for consistent handling
+ const excludeSet = excludeConnectionIds instanceof Set
+ ? excludeConnectionIds
+ : (excludeConnectionIds ? new Set([excludeConnectionIds]) : new Set());
// Acquire mutex to prevent race conditions
const currentMutex = selectionMutex;
let resolveMutex;
@@ -27,7 +31,7 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul
const providerId = resolveProviderId(provider);
const connections = await getProviderConnections({ provider: providerId, isActive: true });
- log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeId: ${excludeConnectionId || "none"}, model: ${model || "any"}`);
+ log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeIds: ${excludeSet.size > 0 ? [...excludeSet].join(",") : "none"}, model: ${model || "any"}`);
if (connections.length === 0) {
log.warn("AUTH", `No credentials for ${provider}`);
@@ -36,14 +40,14 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul
// Filter out model-locked and excluded connections
const availableConnections = connections.filter(c => {
- if (excludeConnectionId && c.id === excludeConnectionId) return false;
+ if (excludeSet.has(c.id)) return false;
if (isModelLockActive(c, model)) return false;
return true;
});
log.debug("AUTH", `${provider} | available: ${availableConnections.length}/${connections.length}`);
connections.forEach(c => {
- const excluded = excludeConnectionId && c.id === excludeConnectionId;
+ const excluded = excludeSet.has(c.id);
const locked = isModelLockActive(c, model);
if (excluded || locked) {
const lockUntil = getEarliestModelLockUntil(c);
@@ -72,11 +76,13 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul
}
const settings = await getSettings();
- const strategy = settings.fallbackStrategy || "fill-first";
+ // Per-provider strategy overrides global setting
+ const providerOverride = (settings.providerStrategies || {})[providerId] || {};
+ const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
let connection;
if (strategy === "round-robin") {
- const stickyLimit = settings.stickyRoundRobinLimit || 3;
+ const stickyLimit = providerOverride.stickyRoundRobinLimit || settings.stickyRoundRobinLimit || 3;
// Sort by lastUsed (most recent first) to find current candidate
const byRecency = [...availableConnections].sort((a, b) => {
@@ -178,7 +184,8 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
});
const lockKey = Object.keys(lockUpdate)[0];
- log.warn("AUTH", `${connectionId.slice(0, 8)} locked ${lockKey} for ${Math.round(cooldownMs / 1000)}s [${status}]`);
+ const connName = conn?.displayName || conn?.name || conn?.email || connectionId.slice(0, 8);
+ log.warn("AUTH", `${connName} locked ${lockKey} for ${Math.round(cooldownMs / 1000)}s [${status}]`);
if (provider && status && reason) {
console.error(`❌ ${provider} [${status}]: ${reason}`);
@@ -228,7 +235,8 @@ export async function clearAccountError(connectionId, currentConnection, model =
}
await updateProviderConnection(connectionId, clearObj);
- log.info("AUTH", `Account ${connectionId.slice(0, 8)} cleared lock for model=${model || "__all"}`);
+ const connName = conn?.displayName || conn?.name || conn?.email || connectionId.slice(0, 8);
+ log.info("AUTH", `Account ${connName} cleared lock for model=${model || "__all"}`);
}
/**
diff --git a/src/sse/services/tokenRefresh.js b/src/sse/services/tokenRefresh.js
index ebcf896..759178a 100644
--- a/src/sse/services/tokenRefresh.js
+++ b/src/sse/services/tokenRefresh.js
@@ -19,7 +19,8 @@ import {
getAccessToken as _getAccessToken,
refreshTokenByProvider as _refreshTokenByProvider,
formatProviderCredentials as _formatProviderCredentials,
- getAllAccessTokens as _getAllAccessTokens
+ getAllAccessTokens as _getAllAccessTokens,
+ refreshKiroToken as _refreshKiroToken
} from "open-sse/services/tokenRefresh.js";
export const TOKEN_EXPIRY_BUFFER_MS = BUFFER_MS;
@@ -50,6 +51,9 @@ export const refreshGitHubToken = (refreshToken) =>
export const refreshCopilotToken = (githubAccessToken) =>
_refreshCopilotToken(githubAccessToken, log);
+export const refreshKiroToken = (refreshToken, providerSpecificData) =>
+ _refreshKiroToken(refreshToken, providerSpecificData, log);
+
export const getAccessToken = (provider, credentials) =>
_getAccessToken(provider, credentials, log);
diff --git a/tests/unit/openai-to-claude.test.js b/tests/unit/openai-to-claude.test.js
new file mode 100644
index 0000000..bf0a525
--- /dev/null
+++ b/tests/unit/openai-to-claude.test.js
@@ -0,0 +1,124 @@
+/**
+ * Unit tests for open-sse/translator/request/openai-to-claude.js
+ *
+ * Tests cover:
+ * - openaiToClaudeRequest() - OpenAI to Claude request translation
+ * - Response format handling (json_schema, json_object)
+ */
+
+import { describe, it, expect } from "vitest";
+import { openaiToClaudeRequest } from "../../open-sse/translator/request/openai-to-claude.js";
+
+describe("openaiToClaudeRequest", () => {
+ describe("response_format handling", () => {
+ it("should inject JSON schema instructions for json_schema type", () => {
+ const body = {
+ messages: [{ role: "user", content: "What is 2+2?" }],
+ response_format: {
+ type: "json_schema",
+ json_schema: {
+ name: "math_response",
+ schema: {
+ type: "object",
+ properties: {
+ answer: { type: "number" },
+ explanation: { type: "string" }
+ },
+ required: ["answer", "explanation"]
+ }
+ }
+ }
+ };
+
+ const result = openaiToClaudeRequest("claude-sonnet-4.5", body, false);
+
+ // Should have system array with instructions
+ expect(result.system).toBeDefined();
+ expect(Array.isArray(result.system)).toBe(true);
+
+ // Check that system prompt includes schema
+ const systemText = result.system
+ .filter(s => s.type === "text")
+ .map(s => s.text)
+ .join("\n");
+
+ expect(systemText).toContain("You must respond with valid JSON");
+ expect(systemText).toContain("\"answer\"");
+ expect(systemText).toContain("\"explanation\"");
+ expect(systemText).toContain("Respond ONLY with the JSON object");
+ });
+
+ it("should inject basic JSON instructions for json_object type", () => {
+ const body = {
+ messages: [{ role: "user", content: "Give me a JSON object" }],
+ response_format: {
+ type: "json_object"
+ }
+ };
+
+ const result = openaiToClaudeRequest("claude-sonnet-4.5", body, false);
+
+ // Should have system array with instructions
+ expect(result.system).toBeDefined();
+ expect(Array.isArray(result.system)).toBe(true);
+
+ const systemText = result.system
+ .filter(s => s.type === "text")
+ .map(s => s.text)
+ .join("\n");
+
+ expect(systemText).toContain("You must respond with valid JSON");
+ expect(systemText).toContain("Respond ONLY with a JSON object");
+ });
+
+ it("should not modify system prompt when response_format is missing", () => {
+ const body = {
+ messages: [{ role: "user", content: "Hello" }]
+ };
+
+ const result = openaiToClaudeRequest("claude-sonnet-4.5", body, false);
+
+ // Should have system but without JSON instructions
+ expect(result.system).toBeDefined();
+
+ const systemText = result.system
+ .filter(s => s.type === "text")
+ .map(s => s.text)
+ .join("\n");
+
+ // Should NOT contain JSON-specific instructions
+ expect(systemText).not.toContain("You must respond with valid JSON");
+ });
+
+ it("should preserve existing system messages when adding response_format", () => {
+ const body = {
+ messages: [
+ { role: "system", content: "You are a helpful math tutor." },
+ { role: "user", content: "What is 2+2?" }
+ ],
+ response_format: {
+ type: "json_schema",
+ json_schema: {
+ schema: {
+ type: "object",
+ properties: {
+ result: { type: "number" }
+ }
+ }
+ }
+ }
+ };
+
+ const result = openaiToClaudeRequest("claude-sonnet-4.5", body, false);
+
+ // Should preserve original system message
+ const systemText = result.system
+ .filter(s => s.type === "text")
+ .map(s => s.text)
+ .join("\n");
+
+ expect(systemText).toContain("You are a helpful math tutor");
+ expect(systemText).toContain("You must respond with valid JSON");
+ });
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/translator-request-normalization.test.js b/tests/unit/translator-request-normalization.test.js
new file mode 100644
index 0000000..0a67a91
--- /dev/null
+++ b/tests/unit/translator-request-normalization.test.js
@@ -0,0 +1,118 @@
+import { describe, it, expect } from "vitest";
+
+import { FORMATS } from "../../open-sse/translator/formats.js";
+import { translateRequest } from "../../open-sse/translator/index.js";
+import { claudeToOpenAIRequest } from "../../open-sse/translator/request/claude-to-openai.js";
+import { filterToOpenAIFormat } from "../../open-sse/translator/helpers/openaiHelper.js";
+import { parseSSELine } from "../../open-sse/utils/streamHelpers.js";
+
+describe("request normalization", () => {
+ it("claudeToOpenAIRequest flattens text-only content arrays into string", () => {
+ const body = {
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "hi" },
+ { type: "text", text: "there" },
+ ],
+ },
+ ],
+ };
+
+ const result = claudeToOpenAIRequest("gpt-oss:120b", body, true);
+ expect(result.messages[0].content).toBe("hi\nthere");
+ });
+
+ it("claudeToOpenAIRequest preserves multimodal arrays", () => {
+ const body = {
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "describe" },
+ {
+ type: "image",
+ source: {
+ type: "base64",
+ media_type: "image/png",
+ data: "ZmFrZQ==",
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = claudeToOpenAIRequest("gpt-4o", body, true);
+ expect(Array.isArray(result.messages[0].content)).toBe(true);
+ });
+
+ it("filterToOpenAIFormat flattens text-only arrays to string", () => {
+ const body = {
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "a" },
+ { type: "text", text: "b" },
+ ],
+ },
+ ],
+ };
+
+ const result = filterToOpenAIFormat(JSON.parse(JSON.stringify(body)));
+ expect(result.messages[0].content).toBe("a\nb");
+ });
+
+ it("translateRequest keeps /v1/messages Claude->OpenAI text payloads string-safe", () => {
+ const body = {
+ model: "ollama/gpt-oss:120b",
+ system: [{ type: "text", text: "You are helpful." }],
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "hello" },
+ { type: "text", text: "world" },
+ ],
+ },
+ ],
+ stream: true,
+ };
+
+ const result = translateRequest(
+ FORMATS.CLAUDE,
+ FORMATS.OPENAI,
+ "gpt-oss:120b",
+ JSON.parse(JSON.stringify(body)),
+ true,
+ null,
+ "ollama",
+ );
+
+ const userMessage = result.messages.find((m) => m.role === "user");
+ expect(typeof userMessage.content).toBe("string");
+ expect(userMessage.content).toBe("hello\nworld");
+ });
+
+ it("parseSSELine supports provider raw NDJSON stream lines", () => {
+ const raw = JSON.stringify({
+ model: "gpt-oss:120b",
+ message: { role: "assistant", content: "hello" },
+ done: false,
+ });
+
+ const parsed = parseSSELine(raw);
+ expect(parsed).toEqual({
+ model: "gpt-oss:120b",
+ message: { role: "assistant", content: "hello" },
+ done: false,
+ });
+ });
+
+ it("parseSSELine still supports SSE data lines", () => {
+ const parsed = parseSSELine('data: {"choices":[{"delta":{"content":"hi"}}]}');
+ expect(parsed.choices[0].delta.content).toBe("hi");
+ });
+});