mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
feat(installer): detect Termux and skip incompatible setup steps (#1764)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
476
open-sse/executors/t3-chat-web.ts
Normal file
476
open-sse/executors/t3-chat-web.ts
Normal 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();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
26
src/lib/zed-oauth/dockerDetect.ts
Normal file
26
src/lib/zed-oauth/dockerDetect.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
147
tests/unit/kiro-multi-account-isolation.test.ts
Normal file
147
tests/unit/kiro-multi-account-isolation.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user