feat(installer): detect Termux and skip incompatible setup steps (#1764)

This commit is contained in:
diegosouzapw
2026-05-19 10:43:11 -03:00
parent ae07f437b1
commit 3355920db9
25 changed files with 1188 additions and 33 deletions

View File

@@ -20,6 +20,7 @@
- **feat(providers):** improve Cohere provider support, expanding models and accurately updating OpenAI context limits. ([#2313](https://github.com/diegosouzapw/OmniRoute/pull/2313) — thanks @backryun)
- **feat(claude-web):** implement session-based Claude Web executor with auto-refresh authentication — enables direct Claude Web API access without an API key. ([#2283](https://github.com/diegosouzapw/OmniRoute/pull/2283) — thanks @oyi77)
- **feat(skills):** add 5 CLI skill manifests + AgentSkills / OmniSkills dashboard pages — enables external AI agents to discover and invoke OmniRoute capabilities. ([#2284](https://github.com/diegosouzapw/OmniRoute/pull/2284))
- **feat(providers):** add llama.cpp as local provider — `llama-cpp` (alias `llamacpp`) added to `LOCAL_PROVIDERS` and `SELF_HOSTED_CHAT_PROVIDER_IDS`; default base URL `http://127.0.0.1:8080/v1`; no API key required; uses the default OpenAI-compatible executor ([#1980](https://github.com/diegosouzapw/OmniRoute/issues/1980))
- **feat(providers):** bulk add API keys with Single/Bulk tabs.
- **feat(provider):** add Gitlawb Opengateway provider (xiaomi-mimo + gmi-cloud) with hasFree flag support. ([#2314](https://github.com/diegosouzapw/OmniRoute/pull/2314) — thanks @oyi77)
- **feat(ui):** comprehensive dashboard UX rework including simple/advanced modes for RTK/Caveman, human-readable error badges, InfoTooltip/PresetSlider shared components, sidebar subtitles, and provider category filters. ([#2315](https://github.com/diegosouzapw/OmniRoute/pull/2315), [#2316](https://github.com/diegosouzapw/OmniRoute/pull/2316) — thanks @dhaern, @oyi77)

View File

@@ -1,4 +1,4 @@
import { apiFetch } from "../api.mjs";
import { apiFetch, isServerUp } from "../api.mjs";
import { emit } from "../output.mjs";
import { printHeading } from "../io.mjs";
import { getAvailableProviderCategories, loadAvailableProviders } from "../provider-catalog.mjs";
@@ -7,8 +7,10 @@ import {
findProviderConnection,
getProviderApiKey,
listProviderConnections,
updateProviderApiKey,
updateProviderTestResult,
} from "../provider-store.mjs";
import { encryptCredential } from "../encryption.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
import { t } from "../i18n.mjs";
@@ -301,6 +303,179 @@ export async function runValidateCommand(opts = {}) {
}
}
export async function runProvidersRotateCommand(selector, opts = {}) {
if (!selector) {
console.error("Provider connection id or name is required.");
return 2;
}
// --- Resolve connection ---
const { db } = await openOmniRouteDb();
let connection;
try {
connection = findProviderConnection(db, selector);
} finally {
db.close();
}
if (!connection) {
console.error(`Provider connection not found: ${selector}`);
return 2;
}
// --- OAuth short-circuit ---
if (opts.oauth || connection.authType !== "apikey") {
console.log(t("providers.rotate.oauthHint", { provider: connection.provider }));
return 0;
}
// --- Source new key ---
let newKey;
if (opts.fromEnv) {
newKey = process.env[opts.fromEnv];
if (!newKey) {
console.error(t("providers.rotate.envVarEmpty", { var: opts.fromEnv }));
return 2;
}
} else if (opts.newKey) {
newKey = opts.newKey;
} else {
// Interactive prompt (echo-off not strictly needed for a key value, but best practice)
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
newKey = await new Promise((resolve) =>
rl.question(`New API key for ${connection.name}: `, (a) => { rl.close(); resolve(a.trim()); })
);
if (!newKey) {
console.error("No key provided.");
return 2;
}
}
// --- Dry-run ---
if (opts.dryRun) {
console.log(t("providers.rotate.dryRunResult", { name: connection.name, id: connection.id.slice(0, 8) }));
return 0;
}
// --- Confirm ---
if (!opts.yes) {
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) =>
rl.question(t("providers.rotate.confirmPrompt", { name: connection.name, id: connection.id.slice(0, 8) }), resolve)
);
rl.close();
if (!/^y(es|s)?$/i.test(answer)) {
console.log(t("common.cancelled"));
return 0;
}
}
// --- Write ---
const serverUp = await isServerUp();
if (serverUp) {
try {
const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
method: "PATCH",
body: { apiKey: newKey, testStatus: "unknown", lastError: null, rateLimitedUntil: null, backoffLevel: 0 },
retry: false,
acceptNotOk: true,
});
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}
} catch {
// Fall through to direct DB write
const { db: db2 } = await openOmniRouteDb();
try {
updateProviderApiKey(db2, connection.id, encryptCredential(newKey));
} finally {
db2.close();
}
}
} else {
const { db: db2 } = await openOmniRouteDb();
try {
updateProviderApiKey(db2, connection.id, encryptCredential(newKey));
} finally {
db2.close();
}
}
console.log(t("providers.rotate.success", { name: connection.name, id: connection.id.slice(0, 8) }));
// --- Post-rotation test ---
if (!opts.skipTest) {
const { db: db3 } = await openOmniRouteDb();
try {
const fresh = findProviderConnection(db3, connection.id);
if (fresh) {
const result = await runProviderTest(db3, fresh);
if (result.valid) {
console.log(t("providers.rotate.testPassed"));
} else {
console.error(t("providers.rotate.testFailed", { error: result.error }));
}
}
} finally {
db3.close();
}
}
return 0;
}
export async function runProvidersStatusCommand(opts = {}) {
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("providers.status.requiresServer"));
return 3;
}
const res = await apiFetch("/api/providers/expiration", { acceptNotOk: true, retry: false });
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}
const data = await res.json();
const list = data.list || [];
// Optional provider filter
const filter = opts.provider ? String(opts.provider).toLowerCase() : null;
const rows = filter ? list.filter((item) => item.provider?.toLowerCase().includes(filter)) : list;
if (opts.json || opts.output === "json") {
console.log(JSON.stringify({ count: rows.length, connections: rows }, null, 2));
return 0;
}
if (rows.length === 0) {
console.log(t("providers.status.noData"));
return 0;
}
console.log(t("providers.status.header"));
for (const item of rows) {
const shortId = (item.connectionId || item.id || "").slice(0, 8);
const expiry = item.expiresAt ? new Date(item.expiresAt).toLocaleDateString() : "-";
const expiryStatus = item.status || "unknown";
const testStatus = item.testStatus || "unknown";
const cooldown = item.rateLimitedUntil ? new Date(item.rateLimitedUntil).toLocaleString() : "-";
const expiryColor = statusColor(expiryStatus);
const testColor = statusColor(testStatus);
console.log(
`${shortId.padEnd(10)} ${String(item.provider || "").padEnd(14)} ${String(item.name || "").padEnd(24)} ` +
`${expiry.padEnd(12)} ${expiryColor}${expiryStatus.padEnd(8)}\x1b[0m ` +
`${testColor}${testStatus.padEnd(12)}\x1b[0m ${cooldown}`
);
}
return 0;
}
export function registerProviders(program) {
const providers = program.command("providers").description(t("providers.title"));
@@ -357,6 +532,32 @@ export function registerProviders(program) {
if (exitCode !== 0) process.exit(exitCode);
});
providers
.command("rotate <idOrName>")
.description(t("providers.rotate.description"))
.option("--new-key <key>", t("providers.rotate.newKeyOpt"))
.option("--from-env <VAR>", t("providers.rotate.fromEnvOpt"))
.option("--oauth", t("providers.rotate.oauthOpt"))
.option("--yes", t("common.yesOpt"))
.option("--skip-test", t("providers.rotate.skipTestOpt"))
.option("--dry-run", t("providers.rotate.dryRunOpt"))
.action(async (idOrName, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runProvidersRotateCommand(idOrName, { ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
providers
.command("status")
.description(t("providers.status.description"))
.option("--provider <name>", t("providers.status.providerOpt"))
.option("--json", "Print machine-readable JSON")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runProvidersStatusCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
extendProvidersMetrics(providers);
}

View File

@@ -6,6 +6,7 @@ import { platform } from "node:os";
import { t } from "../i18n.mjs";
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..", "..");
@@ -330,7 +331,7 @@ async function onReady(dashboardPort, apiPort, noOpen) {
\x1b[2m Press Ctrl+C to stop\x1b[0m
`);
if (!noOpen) {
if (!noOpen && !isTermux()) {
try {
const open = await import("open");
await open.default(dashboardUrl);

View File

@@ -60,6 +60,28 @@
},
"metric_single": {
"description": "Get a single metric value for a specific connection"
},
"rotate": {
"description": "Rotate the upstream API key for a provider connection",
"newKeyOpt": "New API key value (avoid: prefer --from-env)",
"fromEnvOpt": "Read new key from environment variable VAR",
"oauthOpt": "Trigger OAuth re-authentication flow instead",
"skipTestOpt": "Skip post-rotation connectivity test",
"dryRunOpt": "Print what would change without writing",
"confirmPrompt": "Replace API key for connection \"{name}\" ({id})? [y/N] ",
"dryRunResult": "[dry-run] Would rotate key for \"{name}\" ({id}). No changes made.",
"oauthHint": "OAuth connection — run: omniroute oauth {provider}",
"envVarEmpty": "Environment variable {var} is not set or is empty.",
"success": "Key rotated for \"{name}\". Run `providers test {id}` to verify.",
"testPassed": "Post-rotation test passed.",
"testFailed": "Post-rotation test failed: {error}"
},
"status": {
"description": "Show key health for all provider connections (age, expiry, cooldown)",
"providerOpt": "Filter by provider name",
"header": "ID Provider Name Expiry Status Test Status Cooldown Until",
"noData": "No provider connection data available.",
"requiresServer": "providers status requires the OmniRoute server to be running."
}
},
"keys": {

View File

@@ -60,6 +60,28 @@
},
"metric_single": {
"description": "Obter um único valor de métrica para uma conexão específica"
},
"rotate": {
"description": "Rotacionar a chave de API upstream de uma conexão de provedor",
"newKeyOpt": "Novo valor de chave de API (evite: prefira --from-env)",
"fromEnvOpt": "Ler nova chave da variável de ambiente VAR",
"oauthOpt": "Iniciar fluxo de reautenticação OAuth",
"skipTestOpt": "Pular teste de conectividade pós-rotação",
"dryRunOpt": "Exibir o que seria alterado sem gravar",
"confirmPrompt": "Substituir a chave de API da conexão \"{name}\" ({id})? [s/N] ",
"dryRunResult": "[dry-run] Rotacionaria a chave para \"{name}\" ({id}). Nenhuma alteração feita.",
"oauthHint": "Conexão OAuth — execute: omniroute oauth {provider}",
"envVarEmpty": "A variável de ambiente {var} não está definida ou está vazia.",
"success": "Chave rotacionada para \"{name}\". Execute `providers test {id}` para verificar.",
"testPassed": "Teste pós-rotação aprovado.",
"testFailed": "Teste pós-rotação falhou: {error}"
},
"status": {
"description": "Exibir saúde das chaves de todas as conexões de provedores (idade, validade, cooldown)",
"providerOpt": "Filtrar por nome do provedor",
"header": "ID Provedor Nome Validade Status Status Teste Cooldown Até",
"noData": "Nenhum dado de conexão de provedor disponível.",
"requiresServer": "providers status requer o servidor OmniRoute em execução."
}
},
"keys": {

View File

@@ -251,6 +251,32 @@ export function removeProviderConnectionByProvider(db, provider) {
return result.changes;
}
/**
* Replace the encrypted API key for a connection and clear any cooldown state.
* `encryptedKey` must already be passed through `encryptCredential()`.
*/
export function updateProviderApiKey(db, connectionId, encryptedKey) {
ensureProviderSchema(db);
const now = new Date().toISOString();
const result = db
.prepare(
`UPDATE provider_connections
SET api_key = @apiKey,
test_status = 'unknown',
last_error = NULL,
last_error_at = NULL,
last_error_type = NULL,
last_error_source = NULL,
error_code = NULL,
rate_limited_until = NULL,
backoff_level = 0,
updated_at = @updatedAt
WHERE id = @id`
)
.run({ id: connectionId, apiKey: encryptedKey, updatedAt: now });
return result.changes;
}
export function updateProviderTestResult(db, connectionId, result) {
ensureProviderSchema(db);
const now = new Date().toISOString();

View File

@@ -30,6 +30,7 @@ import { DeepSeekWebExecutor } from "./deepseek-web.ts";
import { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts";
import { CopilotWebExecutor } from "./copilot-web.ts";
import { VeoAIFreeWebExecutor } from "./veoaifree-web.ts";
import { T3ChatWebExecutor } from "./t3-chat-web.ts";
const executors = {
antigravity: new AntigravityExecutor(),
@@ -84,6 +85,8 @@ const executors = {
copilot: new CopilotWebExecutor(), // Alias
"veoaifree-web": new VeoAIFreeWebExecutor(),
"veo-free": new VeoAIFreeWebExecutor(), // Alias
"t3-web": new T3ChatWebExecutor(),
t3chat: new T3ChatWebExecutor(), // Alias
};
const defaultCache = new Map();
@@ -132,3 +135,4 @@ export { CopilotWebExecutor } from "./copilot-web.ts";
export { VeoAIFreeWebExecutor } from "./veoaifree-web.ts";
export { DeepSeekWebExecutor } from "./deepseek-web.ts";
export { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts";
export { T3ChatWebExecutor } from "./t3-chat-web.ts";

View File

@@ -0,0 +1,476 @@
// ## Known TODOs — Requires Manual DevTools Capture (Step 0 from plan #1909)
//
// Before this skeleton can serve live traffic, a human must open https://t3.chat
// in Chrome with DevTools → Network open, send a chat message while logged in,
// and capture the following:
//
// TODO(post-devtools-capture): Confirm the exact Convex HTTP action endpoint URL.
// Current guess based on Convex pattern: "https://t3.chat/api/chat"
// Alternative guesses: "https://t3.chat/api/sync/streamRoom",
// "https://api.t3.chat/api/chat", or a convex.cloud deployment URL.
// Reference: T3Router Rust source (github.com/vibheksoni/t3router) BASE_URL const.
//
// TODO(post-devtools-capture): Confirm whether `convex-session-id` is sent as an
// HTTP request *header* (current assumption) or as a field in the request *body*.
// Also confirm the exact header/field name (e.g. "convex-session-id",
// "x-convex-session-id", or "sessionId").
//
// TODO(post-devtools-capture): Confirm whether the response is:
// (a) SSE text/event-stream — implement transformT3SSE fully.
// (b) Chunked newline-delimited JSON — adapt decoder.
// (c) Full JSON (non-streaming) — use collectContent path only.
//
// TODO(post-devtools-capture): Confirm the SSE chunk schema — specifically:
// - Which field path contains the incremental text content.
// - What the end-of-stream marker looks like ("[DONE]", a `status` field, etc.).
//
// TODO(post-devtools-capture): Confirm free-tier model IDs (may differ from Pro
// model IDs in providerRegistry.ts). Update registry entries accordingly.
//
// TODO(post-devtools-capture): Confirm the exact request body fields:
// - Field name for messages (current guess: "messages" in OpenAI format).
// - Field name for model (current guess: "model").
// - Whether a conversation/thread ID is required.
// - Whether "stream" is a supported field.
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
export const T3_CHAT_BASE = "https://t3.chat";
// TODO(post-devtools-capture): Replace with confirmed endpoint URL.
// Guesses based on Convex HTTP action pattern and reference implementations:
// - https://t3.chat/api/chat
// - https://t3.chat/api/sync/streamRoom
// Check T3Router Rust source for the BASE_URL constant before going live.
const COMPLETION_URL = `${T3_CHAT_BASE}/api/chat`;
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
// ── Types ────────────────────────────────────────────────────────────────
export interface T3ChatCredentials {
cookies: string;
convexSessionId: string;
}
// ── Helpers ──────────────────────────────────────────────────────────────
function validateCredentials(creds: unknown): creds is T3ChatCredentials {
const raw =
typeof creds === "object" && creds !== null ? (creds as Record<string, unknown>) : {};
return (
typeof raw.cookies === "string" &&
raw.cookies.length > 0 &&
typeof raw.convexSessionId === "string" &&
raw.convexSessionId.length > 0
);
}
function buildErrorResponse(status: number, message: string): Response {
return new Response(
JSON.stringify({
error: {
message: sanitizeErrorMessage(message),
type: "upstream_error",
code: `HTTP_${status}`,
},
}),
{ status, headers: { "Content-Type": "application/json" } }
);
}
// ── SSE Transform (t3.chat Convex → OpenAI) ──────────────────────────────
//
// TODO(post-devtools-capture): Implement the actual chunk extraction logic.
// The field paths below are best guesses based on the Convex streaming protocol.
// Common Convex patterns: { type: "text", text: "..." } or { delta: "..." }.
// Replace `chunk.text ?? chunk.delta ?? chunk.content` with the real field path.
function transformT3SSE(t3Stream: ReadableStream, model: string): ReadableStream {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const id = `chatcmpl-t3-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const created = Math.floor(Date.now() / 1000);
let emittedRole = false;
return new ReadableStream({
async start(controller) {
const reader = t3Stream.getReader();
let buffer = "";
const emit = (obj: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
};
const chunk = (delta: object, finish?: string) => {
emit({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta, finish_reason: finish ?? null }],
});
};
const close = () => {
if (!emittedRole) {
emittedRole = true;
chunk({ role: "assistant", content: "" });
}
chunk({}, "stop");
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (payload === "[DONE]") {
close();
return;
}
let data: Record<string, unknown>;
try {
data = JSON.parse(payload);
} catch {
continue;
}
// TODO(post-devtools-capture): Replace this extraction with the real
// field path from the captured Convex SSE chunk structure.
// Current guess covers common Convex streaming patterns.
const textContent =
(data as any)?.text ??
(data as any)?.delta ??
(data as any)?.content ??
(data as any)?.v?.text ??
null;
if (typeof textContent === "string" && textContent.length > 0) {
if (!emittedRole) {
emittedRole = true;
chunk({ role: "assistant", content: "" });
}
chunk({ content: textContent });
}
// TODO(post-devtools-capture): Replace with real end-of-stream detection.
// Convex commonly uses: { type: "done" }, { status: "complete" },
// { done: true }, or a specific event type.
const isDone =
(data as any)?.type === "done" ||
(data as any)?.done === true ||
(data as any)?.status === "complete" ||
(data as any)?.finish_reason === "stop";
if (isDone) {
close();
return;
}
}
}
} catch {
// Stream error — fall through to close
}
close();
},
});
}
async function collectSSEContent(t3Stream: ReadableStream): Promise<string> {
const decoder = new TextDecoder();
const reader = t3Stream.getReader();
let buffer = "";
const parts: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (payload === "[DONE]") break;
try {
const data = JSON.parse(payload);
// TODO(post-devtools-capture): Use real field path.
const textContent =
(data as any)?.text ??
(data as any)?.delta ??
(data as any)?.content ??
(data as any)?.v?.text ??
null;
if (typeof textContent === "string") parts.push(textContent);
} catch {
// skip
}
}
}
return parts.join("");
}
// ── Executor ─────────────────────────────────────────────────────────────
export class T3ChatWebExecutor extends BaseExecutor {
constructor() {
super("t3-web", { baseUrl: T3_CHAT_BASE });
}
async testConnection(
credentials: Record<string, unknown>,
signal?: AbortSignal
): Promise<boolean> {
try {
if (!validateCredentials(credentials)) return false;
// TODO(post-devtools-capture): Replace with a lightweight confirmed probe.
// Current guess: HEAD or GET to T3_CHAT_BASE checks reachability.
// A better probe might be a lightweight OPTIONS or an auth-gated endpoint.
const resp = await fetch(T3_CHAT_BASE, {
method: "HEAD",
headers: {
"User-Agent": USER_AGENT,
Cookie: credentials.cookies,
},
signal,
});
// A 200/302/404 all indicate the site is reachable and the cookie was accepted
// without a hard 401. This is a best-effort probe until a proper endpoint is confirmed.
return resp.status < 500;
} catch {
return false;
}
}
async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) {
const bodyObj = (body || {}) as Record<string, unknown>;
const messages = (Array.isArray(bodyObj.messages) ? bodyObj.messages : []) as Array<{
role: string;
content: string | unknown;
}>;
const rawCreds = credentials as unknown as Record<string, unknown>;
// 1. Validate credentials
if (!validateCredentials(rawCreds)) {
const missing = !rawCreds.cookies
? "cookies"
: !rawCreds.convexSessionId
? "convexSessionId"
: "both fields";
return {
response: buildErrorResponse(
400,
`t3.chat credentials invalid: missing or empty ${missing}. Both 'cookies' and 'convexSessionId' are required.`
),
url: COMPLETION_URL,
headers: {},
transformedBody: body,
};
}
const { cookies, convexSessionId } = rawCreds as T3ChatCredentials;
try {
// 2. Build request headers
// TODO(post-devtools-capture): Confirm whether convex-session-id is a header
// or a body field. Current assumption: HTTP header.
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
Accept: "text/event-stream, application/json",
Cookie: cookies,
// TODO(post-devtools-capture): Confirm header name — may be "x-convex-session-id"
// or sent as a body field instead.
"convex-session-id": convexSessionId,
Referer: `${T3_CHAT_BASE}/`,
Origin: T3_CHAT_BASE,
};
// 3. Build request payload
// TODO(post-devtools-capture): Confirm all field names from captured network traffic.
// Current guess: OpenAI-compatible messages array + model passthrough.
const requestPayload: Record<string, unknown> = {
model,
messages,
stream: stream !== false,
};
log?.info?.("T3-CHAT-WEB", `POST ${COMPLETION_URL} model=${model}`);
const resp = await fetch(COMPLETION_URL, {
method: "POST",
headers,
body: JSON.stringify(requestPayload),
signal,
});
// 4. Handle HTTP errors
if (!resp.ok) {
const status = resp.status;
let errMsg = `t3.chat API error (${status})`;
if (status === 401 || status === 403) {
errMsg =
"t3.chat session expired or unauthorized — re-paste your cookies and convex-session-id.";
} else if (status === 429) {
errMsg = "t3.chat rate limited. Wait and retry.";
}
log?.warn?.("T3-CHAT-WEB", errMsg);
return {
response: buildErrorResponse(status, errMsg),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
}
const ct = resp.headers.get("content-type") || "";
// 5. Non-streaming full JSON response path
if (ct.includes("application/json")) {
const json = await resp.json();
// Check for error in JSON body
if (json?.error) {
const errMsg = `t3.chat error: ${json.error?.message ?? JSON.stringify(json.error)}`;
log?.warn?.("T3-CHAT-WEB", errMsg);
return {
response: buildErrorResponse(502, errMsg),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
}
// If the JSON already looks like an OpenAI response, return it directly.
// Otherwise wrap it.
if (json?.choices) {
return {
response: new Response(JSON.stringify(json), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
}
// TODO(post-devtools-capture): Map the actual t3.chat non-streaming response
// shape to OpenAI format once the real field names are confirmed.
const content =
(json as any)?.content ??
(json as any)?.text ??
(json as any)?.message?.content ??
"";
const openaiResponse = {
id: `chatcmpl-t3-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: model || "unknown",
choices: [
{
index: 0,
message: { role: "assistant", content: String(content) },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
return {
response: new Response(JSON.stringify(openaiResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
}
// 6. Streaming SSE path
if (!resp.body) {
return {
response: buildErrorResponse(502, "t3.chat returned an empty response body"),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
}
if (stream !== false) {
const openaiStream = transformT3SSE(resp.body, model || "unknown");
return {
response: new Response(openaiStream, {
status: 200,
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
}),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
}
// Non-streaming: collect SSE content and return OpenAI JSON
const content = await collectSSEContent(resp.body);
const openaiResponse = {
id: `chatcmpl-t3-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: model || "unknown",
choices: [
{
index: 0,
message: { role: "assistant", content },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
return {
response: new Response(JSON.stringify(openaiResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
url: COMPLETION_URL,
headers,
transformedBody: requestPayload,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log?.error?.("T3-CHAT-WEB", `Execute failed: ${msg}`);
if (err instanceof DOMException && err.name === "AbortError") {
return {
response: buildErrorResponse(499, "Request cancelled"),
url: COMPLETION_URL,
headers: {},
transformedBody: body,
};
}
return {
response: buildErrorResponse(502, `t3.chat connection error: ${msg}`),
url: COMPLETION_URL,
headers: {},
transformedBody: body,
};
}
}
}
export const t3ChatWebExecutor = new T3ChatWebExecutor();

View File

@@ -3915,7 +3915,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
} catch {
@@ -3933,7 +3934,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
} else {
@@ -3951,7 +3953,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
} else if (isContextOverflowError(statusCode, message)) {
@@ -3993,7 +3996,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
} catch {
@@ -4011,7 +4015,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
} else {
@@ -4029,7 +4034,8 @@ export async function handleChatCore({
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
upstreamErrorType,
upstreamErrorBody
);
}
} else {

View File

@@ -14,6 +14,7 @@ interface ErrorResponseBody {
type?: string;
code?: string;
};
upstream_details?: Record<string, unknown> | null; // sanitized upstream provider body
}
// Length cap protects against pathological inputs even before tokenization.
@@ -56,21 +57,66 @@ export function sanitizeErrorMessage(message: unknown): string {
return parts.join("");
}
const BLOCKED_KEYS = /stack|trace|path|file|cwd|dir|password|secret|token|key/i;
const MAX_DEPTH = 4;
/**
* Recursively sanitize an arbitrary JSON value from an upstream provider body.
* - Strings: run through sanitizeErrorMessage (strips stacks + absolute paths).
* - Keys matching BLOCKED_KEYS are dropped (credential/path guards).
* - Depth capped at MAX_DEPTH to prevent pathological nesting.
* - Arrays capped at 32 elements.
* - Returns null for null/undefined/non-JSON-serializable values.
*/
export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
if (depth > MAX_DEPTH) return "[truncated]";
if (value === null || value === undefined) return null;
if (typeof value === "string") return sanitizeErrorMessage(value);
if (typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) {
return value.slice(0, 32).map((v) => sanitizeUpstreamDetails(v, depth + 1));
}
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (BLOCKED_KEYS.test(k)) continue;
out[k] = sanitizeUpstreamDetails(v, depth + 1);
}
return out;
}
return null;
}
/**
* Build OpenAI-compatible error response body. Message is always sanitized
* so callers do not need to remember to strip stack traces themselves.
* Optional third argument `upstreamDetails` (raw parsed provider body) is
* sanitized by sanitizeUpstreamDetails before inclusion as `upstream_details`.
*/
export function buildErrorBody(statusCode: number, message: string): ErrorResponseBody {
export function buildErrorBody(
statusCode: number,
message: string,
upstreamDetails?: unknown
): ErrorResponseBody {
const errorInfo = getErrorInfo(statusCode);
const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode);
return {
const body: ErrorResponseBody = {
error: {
message: safeMessage,
type: errorInfo.type,
code: errorInfo.code,
},
};
if (upstreamDetails !== undefined && upstreamDetails !== null) {
const sanitized = sanitizeUpstreamDetails(upstreamDetails);
if (sanitized !== null && typeof sanitized === "object" && !Array.isArray(sanitized)) {
body.upstream_details = sanitized as Record<string, unknown>;
}
}
return body;
}
/**
@@ -255,9 +301,10 @@ export function createErrorResult(
message: string,
retryAfterMs: number | null = null,
errorCode?: string,
errorType?: string
errorType?: string,
upstreamDetails?: unknown
) {
const body = buildErrorBody(statusCode, message);
const body = buildErrorBody(statusCode, message, upstreamDetails);
if (errorCode) {
body.error.code = errorCode;
}

View File

@@ -27,7 +27,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { PUBLISHED_BUILD_ARCH, PUBLISHED_BUILD_PLATFORM } from "./native-binary-compat.mjs";
import { hasStandaloneAppBundle } from "./postinstallSupport.mjs";
import { hasStandaloneAppBundle, isTermux } from "./postinstallSupport.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -136,7 +136,7 @@ async function fixBetterSqliteBinary() {
const { execSync } = await import("node:child_process");
// On Android/Termux, rebuild from source with --build-from-source flag
const isAndroid = process.platform === "android";
const isAndroid = process.platform === "android" || isTermux();
const rebuildCmd = isAndroid
? "npm install better-sqlite3 --build-from-source --force"
: "npm rebuild better-sqlite3";
@@ -196,8 +196,13 @@ async function fixBetterSqliteBinary() {
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634
*/
async function fixWreqJsBinary() {
if (process.platform === "android") {
console.log(" [postinstall] wreq-js: skipped on android (unsupported platform)");
// wreq-js native module is not loadable in Termux (libgcc path mismatch).
// The runtime already falls back gracefully when wreq-js is unavailable.
if (process.platform === "android" || isTermux()) {
console.log(
" [postinstall] wreq-js: skipped on Termux/Android " +
"(libgcc not available — OAuth TLS fingerprinting will use the fallback path)"
);
return;
}

View File

@@ -14,3 +14,25 @@ import { join } from "node:path";
export function hasStandaloneAppBundle(rootDir) {
return existsSync(join(rootDir, "app", "server.js"));
}
/**
* Returns true when running inside a Termux environment on Android.
*
* Node.js on Termux reports process.platform === "linux" (not "android"),
* so OS-level platform checks are insufficient. Use Termux-specific signals:
* 1. TERMUX_VERSION env var (set by Termux bootstrap, most reliable)
* 2. PREFIX env var containing "com.termux"
* 3. Filesystem probe at /data/data/com.termux (last resort, no env needed)
*
* @param {object} [env] Override process.env for testing.
* @returns {boolean}
*/
export function isTermux(env = process.env) {
if (env.TERMUX_VERSION) return true;
if (typeof env.PREFIX === "string" && env.PREFIX.includes("com.termux")) return true;
try {
return existsSync("/data/data/com.termux");
} catch {
return false;
}
}

View File

@@ -222,6 +222,23 @@ async function saveAndRespond(
if (result.region) providerSpecificData.region = result.region;
if (profileArn) providerSpecificData.profileArn = profileArn;
// For the SSO-cache fallback path the token came from ~/.aws/sso/cache and has no
// per-connection OIDC client. Register one now so this connection gets an isolated
// refresh session (#2328). The SQLite path already sets result.clientId.
if (!result.clientId) {
try {
const reg = await runWithProxyContext(proxy, () => kiroService.registerClient());
providerSpecificData.clientId = reg.clientId;
providerSpecificData.clientSecret = reg.clientSecret;
providerSpecificData.region = "us-east-1";
if (reg.clientSecretExpiresAt) {
providerSpecificData.clientSecretExpiresAt = reg.clientSecretExpiresAt;
}
} catch (err) {
console.warn("[kiro auto-import] registerClient failed, continuing without isolated client:", err);
}
}
// Refresh token to get a fresh access token and confirm it works
const refreshed = await runWithProxyContext(proxy, () =>
kiroService.refreshToken(refreshToken, providerSpecificData)

View File

@@ -44,16 +44,18 @@ export async function POST(request: Request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { refreshToken } = validation.data;
const { refreshToken, region } = validation.data;
const kiroService = new KiroService();
// Resolve proxy for this provider (provider-level → global → direct)
const proxy = await resolveProxyForProvider(targetProvider);
// Validate and refresh token (through proxy if configured)
// Validate and refresh token (through proxy if configured).
// validateImportToken also calls registerClient() to obtain a per-connection OIDC
// client pair so multiple Kiro accounts do not share a single backend session (#2328).
const tokenData = await runWithProxyContext(proxy, () =>
kiroService.validateImportToken(refreshToken.trim())
kiroService.validateImportToken(refreshToken.trim(), region)
);
// Extract email from JWT if available
@@ -71,6 +73,16 @@ export async function POST(request: Request) {
profileArn: tokenData.profileArn,
authMethod: "imported",
provider: "Imported",
...(tokenData.clientId
? {
clientId: tokenData.clientId,
clientSecret: tokenData.clientSecret,
region,
...(tokenData.clientSecretExpiresAt
? { clientSecretExpiresAt: tokenData.clientSecretExpiresAt }
: {}),
}
: {}),
},
testStatus: "active",
});

View File

@@ -44,6 +44,20 @@ export async function POST(request: Request) {
// Exchange code for tokens (redirect_uri handled internally)
const tokenData = await kiroService.exchangeSocialCode(code, codeVerifier);
// Register an independent OIDC client for this connection so multiple Kiro accounts
// do not share a single backend session (#2328). Failure is non-fatal; the
// connection will degrade to the shared social-auth refresh path.
let oidcRegistration: {
clientId?: string;
clientSecret?: string;
clientSecretExpiresAt?: number;
} = {};
try {
oidcRegistration = await kiroService.registerClient();
} catch (err) {
console.warn("[kiro social-exchange] registerClient failed, continuing without it:", err);
}
// Extract email from JWT if available
const email = kiroService.extractEmailFromJWT(tokenData.accessToken);
@@ -59,6 +73,16 @@ export async function POST(request: Request) {
profileArn: tokenData.profileArn,
authMethod: provider, // "google" or "github"
provider: provider.charAt(0).toUpperCase() + provider.slice(1),
...(oidcRegistration.clientId
? {
clientId: oidcRegistration.clientId,
clientSecret: oidcRegistration.clientSecret,
region: "us-east-1",
...(oidcRegistration.clientSecretExpiresAt
? { clientSecretExpiresAt: oidcRegistration.clientSecretExpiresAt }
: {}),
}
: {}),
},
testStatus: "active",
});

View File

@@ -14,6 +14,7 @@ import { discoverZedCredentials, isZedInstalled } from "@/lib/zed-oauth/keychain
import { partitionZedCredentials } from "@/lib/zed-oauth/importUtils";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createProviderConnection } from "@/lib/db/providers";
import { isRunningInDocker } from "@/lib/zed-oauth/dockerDetect";
interface ImportResponse {
success: boolean;
@@ -27,6 +28,7 @@ interface ImportResponse {
}>;
error?: string;
zedInstalled?: boolean;
zedDockerEnvironment?: boolean;
}
export async function POST(request: Request): Promise<NextResponse<ImportResponse> | Response> {
@@ -82,7 +84,7 @@ export async function POST(request: Request): Promise<NextResponse<ImportRespons
});
savedCount++;
} catch (err) {
console.error(`[Zed Import] Failed to save credential for ${cred.provider}:`, err);
console.error("[Zed Import] Failed to save credential for %s:", cred.provider, err);
}
}

View File

@@ -240,27 +240,47 @@ export class KiroService {
}
/**
* Validate and import refresh token
* Validate and import refresh token.
* Registers a dedicated OIDC client so this connection has an isolated refresh session.
* If registerClient() fails (network issue, OIDC service down), the import still
* succeeds and the connection falls back to the shared social-auth refresh path.
*/
async validateImportToken(refreshToken: string) {
async validateImportToken(refreshToken: string, region: string = "us-east-1") {
// Validate token format
if (!refreshToken.startsWith("aorAAAAAG")) {
throw new Error("Invalid token format. Token should start with aorAAAAAG...");
}
// Try to refresh to validate
let result: Awaited<ReturnType<typeof this.refreshToken>>;
try {
const result = await this.refreshToken(refreshToken);
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken || refreshToken,
profileArn: result.profileArn,
expiresIn: result.expiresIn,
authMethod: "imported",
};
result = await this.refreshToken(refreshToken);
} catch (error: any) {
throw new Error(`Token validation failed: ${error.message}`);
}
// Register an independent OIDC client for this connection so multiple accounts
// do not share a single Kiro backend session (issue #2328).
let clientId: string | undefined;
let clientSecret: string | undefined;
let clientSecretExpiresAt: number | undefined;
try {
const registration = await this.registerClient(region);
clientId = registration.clientId;
clientSecret = registration.clientSecret;
clientSecretExpiresAt = registration.clientSecretExpiresAt;
} catch (err: any) {
console.warn("[kiro import] registerClient failed, continuing without isolated client:", err);
}
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken || refreshToken,
profileArn: result.profileArn,
expiresIn: result.expiresIn,
authMethod: "imported",
...(clientId ? { clientId, clientSecret, clientSecretExpiresAt } : {}),
};
}
/**

View File

@@ -0,0 +1,26 @@
import fs from "fs";
/**
* Returns true when OmniRoute appears to be running inside a Docker container.
* Uses two complementary heuristics that work on Linux-based Docker images:
* 1. Presence of /.dockerenv (written by Docker at container startup).
* 2. The string "docker" appearing in /proc/1/cgroup (Linux only).
*
* This is intentionally a best-effort check; false negatives on exotic runtimes
* (e.g. podman without Docker compatibility) are acceptable — the caller degrades
* gracefully and still surfaces the manual-import option.
*/
export function isRunningInDocker(): boolean {
try {
if (fs.existsSync("/.dockerenv")) return true;
} catch {
// ignore — not Linux or permission denied
}
try {
const cgroup = fs.readFileSync("/proc/1/cgroup", "utf8");
if (cgroup.includes("docker")) return true;
} catch {
// ignore — not Linux or /proc not mounted
}
return false;
}

View File

@@ -13,6 +13,7 @@
import fs from "fs";
import os from "os";
import path from "path";
import { isRunningInDocker } from "./dockerDetect";
/** Minimal keytar surface (CJS/native; typings may not expose `default`). */
type KeytarModule = {
@@ -132,7 +133,7 @@ export async function discoverZedCredentials(): Promise<ZedCredential[]> {
});
}
} catch (error: any) {
console.debug(`No credentials found for ${pattern}:`, error?.message || error);
console.debug("No credentials found for %s:", pattern, error?.message || error);
// Continue to next pattern
}
}
@@ -186,7 +187,7 @@ export async function getZedCredential(provider: string): Promise<ZedCredential
}
}
} catch (error: any) {
console.debug(`Failed to get credential for ${pattern}:`, error?.message || error);
console.debug("Failed to get credential for %s:", pattern, error?.message || error);
}
}

View File

@@ -249,6 +249,21 @@ export const WEB_COOKIE_PROVIDERS = {
freeNote: "Free video generation — VEO 3.1, Seedance. 6 requests/hour.",
authHint: "No auth required. Rate limited to 6 requests/hour per IP.",
},
"t3-web": {
id: "t3-web",
alias: "t3chat",
name: "t3.chat (Pro/Free)",
icon: "auto_awesome",
color: "#7C3AED",
textIcon: "T3",
website: "https://t3.chat",
hasFree: true,
freeNote: "Free tier gives limited model access. Pro ($8/month) unlocks 50+ models.",
authHint:
"Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. " +
"Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. " +
"Paste both values here. See provider setup docs for a step-by-step guide.",
},
};
// API Key Providers
@@ -1815,6 +1830,19 @@ export const LOCAL_PROVIDERS = {
localDefault: "http://127.0.0.1:8080/v1",
passthroughModels: true,
},
"llama-cpp": {
id: "llama-cpp",
alias: "llamacpp",
name: "llama.cpp",
icon: "memory",
color: "#795548",
textIcon: "LC",
website: "https://github.com/ggml-org/llama.cpp",
authHint:
"API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port.",
localDefault: "http://127.0.0.1:8080/v1",
passthroughModels: true,
},
triton: {
id: "triton",
alias: "triton",
@@ -2156,6 +2184,7 @@ export const SELF_HOSTED_CHAT_PROVIDER_IDS = new Set([
"vllm",
"lemonade",
"llamafile",
"llama-cpp",
"triton",
"docker-model-runner",
"xinference",

View File

@@ -1416,6 +1416,7 @@ export const cursorImportSchema = z.object({
export const kiroImportSchema = z.object({
refreshToken: z.string().trim().min(1, "Refresh token is required"),
region: z.string().trim().default("us-east-1"),
});
export const kiroSocialExchangeSchema = z.object({

View File

@@ -0,0 +1,147 @@
/**
* Tests for Kiro multi-account isolation (issue #2328).
*
* Each OmniRoute connection must own its own OIDC client registration
* (clientId + clientSecret) so that refreshing or re-authenticating one
* account does not invalidate another account's refresh token.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { KiroService } from "../../src/lib/oauth/services/kiro.ts";
// ── helpers ───────────────────────────────────────────────────────────────────
function withMockedFetch(impl: typeof fetch, fn: () => Promise<void>) {
const original = globalThis.fetch;
globalThis.fetch = impl;
return fn().finally(() => {
globalThis.fetch = original;
});
}
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
/**
* Build a fetch mock that handles:
* - /token → returns a minimal token refresh response
* - /client/register → returns the given registration pair
*/
function buildFetchMock(registration: { clientId: string; clientSecret: string; clientSecretExpiresAt?: number }) {
return (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/client/register")) {
return jsonResponse(registration);
}
// Treat any other URL as a social-auth/refresh endpoint
return jsonResponse({
accessToken: "at-mock",
refreshToken: "rt-next-mock",
expiresIn: 3600,
});
}) as typeof fetch;
}
// A valid-looking Kiro refresh token (must start with "aorAAAAAG")
const VALID_REFRESH_TOKEN = "aorAAAAAG-mock-refresh-token-for-tests";
// ── tests ─────────────────────────────────────────────────────────────────────
test("validateImportToken registers a client and returns clientId + clientSecret", async () => {
const service = new KiroService();
const reg = { clientId: "test-client-id", clientSecret: "test-client-secret", clientSecretExpiresAt: 9999999999 };
await withMockedFetch(buildFetchMock(reg), async () => {
const result = await service.validateImportToken(VALID_REFRESH_TOKEN);
assert.equal(result.clientId, reg.clientId, "clientId should be returned");
assert.equal(result.clientSecret, reg.clientSecret, "clientSecret should be returned");
assert.equal(result.clientSecretExpiresAt, reg.clientSecretExpiresAt, "clientSecretExpiresAt should be returned");
assert.equal(result.authMethod, "imported");
assert.equal(result.accessToken, "at-mock");
});
});
test("validateImportToken succeeds without clientId when registerClient fails", async () => {
const service = new KiroService();
let callCount = 0;
await withMockedFetch(async (input) => {
const url = String(input);
callCount++;
if (url.endsWith("/client/register")) {
return new Response("Service Unavailable", { status: 503 });
}
return jsonResponse({
accessToken: "at-degraded",
refreshToken: "rt-degraded",
expiresIn: 3600,
});
}, async () => {
// Should not throw even though registerClient fails
const result = await service.validateImportToken(VALID_REFRESH_TOKEN);
assert.equal(result.accessToken, "at-degraded", "import should succeed with a degraded token");
assert.equal(result.authMethod, "imported");
// clientId must not be set — the connection degrades to shared social-auth path
assert.equal(result.clientId, undefined, "clientId should be absent on degraded import");
assert.equal(result.clientSecret, undefined, "clientSecret should be absent on degraded import");
});
assert.ok(callCount >= 1, "fetch should have been called at least once");
});
test("validateImportToken throws when token format is invalid", async () => {
const service = new KiroService();
await assert.rejects(
() => service.validateImportToken("invalid-token-does-not-start-correctly"),
/Invalid token format/
);
});
test("two validateImportToken calls return different clientIds when registerClient returns distinct pairs", async () => {
const service = new KiroService();
let registrationIndex = 0;
const registrations = [
{ clientId: "client-alpha", clientSecret: "secret-alpha" },
{ clientId: "client-beta", clientSecret: "secret-beta" },
];
const mockFetch: typeof fetch = async (input) => {
const url = String(input);
if (url.endsWith("/client/register")) {
return jsonResponse(registrations[registrationIndex++] ?? registrations[0]);
}
return jsonResponse({ accessToken: "at", refreshToken: "rt", expiresIn: 3600 });
};
await withMockedFetch(mockFetch, async () => {
const result1 = await service.validateImportToken(VALID_REFRESH_TOKEN);
const result2 = await service.validateImportToken(VALID_REFRESH_TOKEN);
assert.notEqual(result1.clientId, result2.clientId,
"each import call should receive a distinct clientId for session isolation");
assert.equal(result1.clientId, "client-alpha");
assert.equal(result2.clientId, "client-beta");
});
});
test("registerClient uses the provided region in the OIDC endpoint URL", async () => {
const service = new KiroService();
const calls: string[] = [];
await withMockedFetch(async (input) => {
calls.push(String(input));
return jsonResponse({ clientId: "cid", clientSecret: "csec" });
}, async () => {
await service.registerClient("ap-southeast-1");
});
assert.ok(
calls.some((url) => url.includes("ap-southeast-1")),
"registerClient should call the OIDC endpoint for the specified region"
);
});

View File

@@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { hasStandaloneAppBundle } from "../../scripts/build/postinstallSupport.mjs";
import { hasStandaloneAppBundle, isTermux } from "../../scripts/build/postinstallSupport.mjs";
test("hasStandaloneAppBundle returns false for source checkout without standalone app", () => {
const root = mkdtempSync(join(tmpdir(), "omniroute-postinstall-src-"));
@@ -28,3 +28,20 @@ test("hasStandaloneAppBundle returns true for published standalone app bundle",
rmSync(root, { recursive: true, force: true });
}
});
// isTermux detection
test("isTermux returns false when no termux signals present", () => {
assert.equal(isTermux({}), false);
});
test("isTermux returns true when TERMUX_VERSION is set", () => {
assert.equal(isTermux({ TERMUX_VERSION: "0.119" }), true);
});
test("isTermux returns true when PREFIX contains com.termux", () => {
assert.equal(isTermux({ PREFIX: "/data/data/com.termux/files/usr" }), true);
});
test("isTermux returns false for non-termux PREFIX", () => {
assert.equal(isTermux({ PREFIX: "/usr/local" }), false);
});

View File

@@ -863,21 +863,28 @@ test("local OpenAI-style providers validate without sending Authorization when a
provider: "lemonade",
providerSpecificData: { baseUrl: "http://localhost:13305/api/v1" },
});
const llamaCpp = await validateProviderApiKey({
provider: "llama-cpp",
providerSpecificData: { baseUrl: "http://127.0.0.1:8080/v1" },
});
assert.equal(lmStudio.valid, true);
assert.equal(vllm.valid, true);
assert.equal(lemonade.valid, true);
assert.equal(llamaCpp.valid, true);
assert.deepEqual(
calls.map((call) => call.url),
[
"http://localhost:1234/v1/models",
"http://localhost:8000/v1/models",
"http://localhost:13305/api/v1/models",
"http://127.0.0.1:8080/v1/models",
]
);
assert.equal(calls[0].headers.Authorization, undefined);
assert.equal(calls[1].headers.Authorization, undefined);
assert.equal(calls[2].headers.Authorization, undefined);
assert.equal(calls[3].headers.Authorization, undefined);
} finally {
if (originalAllowPrivateProviderUrls === undefined) {
delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS;
@@ -1981,3 +1988,12 @@ test("validateCommandCodeProvider rejects auth failures and provider outages", a
error: "Provider unavailable (500)",
});
});
test("llama-cpp is classified as a self-hosted chat provider", async () => {
const { isSelfHostedChatProvider, isLocalProvider, providerAllowsOptionalApiKey } =
await import("../../src/shared/constants/providers.ts");
assert.equal(isSelfHostedChatProvider("llama-cpp"), true);
assert.equal(isLocalProvider("llama-cpp"), true);
assert.equal(providerAllowsOptionalApiKey("llama-cpp"), true);
});

View File

@@ -280,6 +280,16 @@ test("providers route accepts managed local, audio, web-cookie and search provid
},
},
},
{
provider: "llama-cpp",
body: {
provider: "llama-cpp",
name: "llama.cpp Local",
providerSpecificData: {
baseUrl: "http://127.0.0.1:8080/v1",
},
},
},
{
provider: "triton",
body: {