Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4d273b7
feat(cli): add CLI tools for pi, omp, letta, codewhale and jcode
hamsa0x7 Jul 6, 2026
921177c
fix(build): resolve CI build and lint errors
hamsa0x7 Jul 6, 2026
facd7ef
Merge remote-tracking branch 'origin/release/v3.8.47' into feat/cli-a…
diegosouzapw Jul 9, 2026
8e15030
fix(cli): resolve merge conflicts, add tests, align error handling fo…
hamsa0x7 Jul 9, 2026
2c54b26
Merge remote-tracking branch 'origin/release/v3.8.47' into feat/cli-a…
diegosouzapw Jul 9, 2026
1d4e2d0
chore(quality): correct cliRuntime.ts file-size baseline to actual po…
hamsa0x7 Jul 9, 2026
d38df21
Merge remote-tracking branch 'origin/release/v3.8.47' into feat/cli-a…
diegosouzapw Jul 9, 2026
aaa91dc
fix(changelog): re-restore #6318 bullet after release sync
hamsa0x7 Jul 9, 2026
327cc0e
fix(merge): restore #6126 clinepass files reverted by release auto-re…
hamsa0x7 Jul 10, 2026
60d9b0e
Merge remote-tracking branch 'origin/release/v3.8.47' into feat/cli-a…
diegosouzapw Jul 10, 2026
34e9e69
fix(db): re-export db/omp from localDb (check:db-rules #2)
hamsa0x7 Jul 10, 2026
dc761d1
fix(db): keep localDb.ts at the 800-line cap after the omp re-export
hamsa0x7 Jul 10, 2026
b685bb4
Merge remote-tracking branch 'origin/release/v3.8.47' into feat/cli-a…
diegosouzapw Jul 10, 2026
70fd303
fix(cli): reduce #6318 scope to omp + letta (pi/codewhale/jcode alrea…
hamsa0x7 Jul 10, 2026
69c744a
test(cli-tools): align cli-tools-schema registry count with omp+letta…
hamsa0x7 Jul 10, 2026
ed79099
fix(cli-tools): omp entry needs docsUrl (CliCatalogEntrySchema requir…
hamsa0x7 Jul 10, 2026
2ed5690
chore(quality): cliTools.ts frozen 915→916 (+1 omp docsUrl line, own …
hamsa0x7 Jul 10, 2026
8036148
Merge remote-tracking branch 'origin/release/v3.8.47' into feat/cli-a…
diegosouzapw Jul 10, 2026
1941a85
chore(changelog): restore base + re-insert #6318 bullet after release…
hamsa0x7 Jul 10, 2026
da4fe62
Merge remote-tracking branch 'origin/release/v3.8.47' into feat/cli-a…
diegosouzapw Jul 10, 2026
e71e683
chore(sync): merge release tip + restore own CHANGELOG bullet
hamsa0x7 Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
- **feat(oauth):** Kiro/Amazon Q auto-import now supports enterprise **External IdP** ("Your organization") logins via Microsoft Entra/Okta/Auth0/OneLogin/Ping/Google/Cognito — these org-issued tokens are not AWS SSO tokens (no `aorAAAAAG`-prefixed refresh token) and can't refresh through the AWS OIDC/Kiro-social path, so `tryAwsSsoCache()` now detects them (`authMethod`/`provider === "externalidp"`) and refreshes via the org IdP's own `tokenEndpoint` (public-client OAuth2 refresh grant, no client secret), persisting `TokenType: EXTERNAL_IDP` gating so the runtime executor sends the header the AWS CodeWhisperer API requires for these accounts; `tokenEndpoint` is SSRF-guarded against an HTTPS + known-IdP-host-suffix allowlist. (#6363 — thanks @artickc)
- **Kiro long-lived API key auth**: new `/api/oauth/kiro/api-key` route + `KiroService.validateApiKey` let a Kiro account be linked with a long-lived AWS CodeWhisperer/Kiro API key instead of the interactive OAuth device flow, with live per-account model discovery (`ListAvailableModels`, 5-minute cache) layered over the existing static registry fallback (#6587 — thanks @strangersp)
- **Chaos Mode**: multi-model parallel/collaborative task execution — dispatches a task to every active provider connection at once (parallel) or chains outputs sequentially so each model builds on the previous one's answer (collaborative), configurable via Dashboard → Chaos Mode (`GET`/`PUT`/`DELETE /api/chaos/config`) and gated per-API-key via a new `chaosModeEnabled` permission (opt-in — disabled by default globally and per key). `POST /api/chaos/run` (dashboard session) and `POST /api/skills/collect/chaos` (external Bearer-token) delegate to a shared `executeChaosRun()` engine (`src/lib/chaos/chaosExecutor.ts`) that dispatches in-process via the established synthetic-Request/route-handler pattern (no network hop, no hardcoded port), with a concurrency cap (max 10 parallel), configurable `max_tokens` (256–128k), a clear error when `stream` is requested, and collaborative-chain info (provider order + input size). Fixes external Bearer-auth bypass and stale config-cache leakage. Regression guard: `tests/unit/chaos-config.test.ts`, `tests/unit/chaos-executor.test.ts`, `tests/unit/chaos-api-routes.test.ts`. ([#6728](https://github.com/diegosouzapw/OmniRoute/pull/6728) — thanks @Moseyuh333)
- **feat(cli):** 2 new CLI tool integrations on Dashboard → CLI Tools — **omp** (Oh My Pi) and **letta** — each with binary detection, config apply/reset, and a settings card following the existing tool-card pattern. Both settings routes shell out to `which omp`/`which letta` to detect the local install, so they're loopback-gated (`LOCAL_ONLY_API_PREFIXES`, Hard Rules #15/#17) in addition to the shared `requireCliToolsAuth()` management-auth guard every cli-tools route requires, and route errors through `sanitizeErrorMessage()`; `src/lib/db/omp.ts` isolates the `omp` CLI's own local SQLite reads behind parameterized queries. (Note: the original PR also proposed **pi**, **codewhale**, and **jcode** integrations — those three had already shipped via a separate PR by the time this one was reconciled, so only omp+letta landed here.) Regression guard: `tests/unit/db/omp.test.ts`, `tests/unit/cli-tools-auth-hardening.test.ts`, `tests/integration/cli-settings-omp.test.ts`, `tests/integration/cli-settings-letta.test.ts`. ([#6318](https://github.com/diegosouzapw/OmniRoute/pull/6318) — thanks @hamsa0x7)

### 🐛 Bug Fixes

Expand Down
5 changes: 3 additions & 2 deletions config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,12 @@
"src/shared/components/OAuthModal.tsx": 993,
"src/shared/components/RequestLoggerV2.tsx": 1629,
"src/shared/components/analytics/charts.tsx": 1558,
"src/shared/constants/cliTools.ts": 875,
"_rebaseline_2026_07_10_6318_omp_letta": "PR #6318 (@hamsa0x7, omp+letta CLI integrations) own growth: cliTools.ts (+53 = 2 registry entries incl. omp docsUrl) and cliRuntime.ts (+18 = runtime-detection wiring for the 2 new tools). Cohesive registry/wiring growth at the existing chokepoints; scope reduced from the original 5 tools (pi/codewhale/jcode shipped separately).",
"src/shared/constants/cliTools.ts": 916,
"src/shared/constants/pricing.ts": 1662,
"src/shared/constants/providers.ts": 3276,
"src/shared/constants/sidebarVisibility.ts": 1198,
"src/shared/services/cliRuntime.ts": 1110,
"src/shared/services/cliRuntime.ts": 1128,
"src/shared/validation/schemas.ts": 2523,
"_rebaseline_2026_06_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 791<cap pre-feature). Cohesive request/logging chokepoint wiring; structural shrink of chat.ts tracked in #3501.",
"_rebaseline_2026_07_09_6678_routing_strategy_9router": "#6678 (SeaXen) — 9router-parity Routing Strategy settings card + per-provider/combo sticky-round-robin override. Own growth: ProviderDetailPageClient.tsx 784->786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit). chat.ts value below reflects the current release tip (grown by other concurrent PRs, e.g. #6640), not this PR own change.",
Expand Down
Binary file added public/providers/letta.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/providers/omp.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
329 changes: 329 additions & 0 deletions src/app/api/cli-tools/letta-settings/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
export const dynamic = "force-dynamic";

import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { exec } from "child_process";
import { promisify } from "util";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { cliAuthOnlyConfigSchema } from "@/shared/validation/schemas/cli";
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";

const execAsync = promisify(exec);

// ── Paths ──────────────────────────────────────────────────────────────
const getLettaDir = () => path.join(os.homedir(), ".letta");
const getSettingsPath = () => path.join(getLettaDir(), "settings.json");
const getLocalBackendDir = () => path.join(getLettaDir(), "lc-local-backend");
const getProviderAuthPath = () => path.join(getLocalBackendDir(), "providers", "auth.json");
const getBackupPath = () =>
path.join(getLocalBackendDir(), "providers", "auth.json.omniroute-backup");

// ── Provider name in auth.json ─────────────────────────────────────────
// "lmstudio" provider type has localModelDiscovery: "openai-compatible"
// which auto-discovers models from /v1/models and shows them in /model picker
// Models appear as "lmstudio/<model-id>" in the CLI
const PROVIDER_NAME = "lmstudio";
const PROVIDER_TYPE = "lmstudio_openai";

// ── Check if Letta CLI is installed ────────────────────────────────────
const checkLettaInstalled = async () => {
try {
const isWindows = os.platform() === "win32";
const command = isWindows ? "where letta" : "which letta";
const env = isWindows
? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` }
: process.env;
await execAsync(command, { windowsHide: true, env });
return true;
} catch {
// Also check if config directory exists (CLI may be installed but not on PATH)
try {
await fs.access(getLettaDir());
return true;
} catch {
return false;
}
}
};

// ── Read settings.json ─────────────────────────────────────────────────
const readSettings = async () => {
try {
const content = await fs.readFile(getSettingsPath(), "utf-8");
return JSON.parse(content);
} catch (error) {
if (error.code === "ENOENT") return {};
throw error;
}
};

// ── Read auth.json ──────────────────────────────────────────────────────
const readAuthFile = async () => {
try {
const content = await fs.readFile(getProviderAuthPath(), "utf-8");
return JSON.parse(content);
} catch (error) {
if (error.code === "ENOENT") return { version: 1, providers: {} };
throw error;
}
};

// ── Check if a base_url points to OmniRoute ──────────────────────────────
const isOmniRouteUrl = (baseUrl) => {
if (!baseUrl) return false;
return baseUrl.includes(":20128") || baseUrl.includes(":3000") || baseUrl.includes("omniroute");
};

// ── Check if OmniRoute is configured ─────────────────────────────────────
const hasOmniRouteConfig = (authFile) => {
if (!authFile?.providers) return false;
const provider = authFile.providers[PROVIDER_NAME];
if (!provider) return false;
return isOmniRouteUrl(provider.base_url);
};

// ── GET - Check Letta CLI and read current settings ────────────────────
export async function GET(request: Request) {
const authError = await requireCliToolsAuth(request);
if (authError) return authError;
try {
const isInstalled = await checkLettaInstalled();

if (!isInstalled) {
return NextResponse.json({
installed: false,
config: null,
message: "Letta CLI is not installed",
});
}

const settings = await readSettings();
const authFile = await readAuthFile();
const provider = authFile?.providers?.[PROVIDER_NAME];

// Detect if lmstudio is already configured for a non-OmniRoute endpoint
let lmstudioConflict = false;
if (provider && !isOmniRouteUrl(provider.base_url)) {
lmstudioConflict = true;
}

return NextResponse.json({
installed: true,
config: authFile,
hasOmniRoute: hasOmniRouteConfig(authFile),
lmstudioConflict,
configPath: getProviderAuthPath(),
letta: {
baseURL: provider?.base_url || null,
},
backendMode: settings.preferredBackendMode || "api",
});
} catch (error) {
return NextResponse.json(
{ error: { message: sanitizeErrorMessage(error) } },
{ status: 500 }
);
}
}

// ── POST - Apply OmniRoute as LM Studio provider + switch to local mode ──
/**
* Steps 1-2 of POST: read the existing Letta auth.json, refuse to clobber a real
* LM Studio configuration unless `overwrite` is set (409 with conflict info), and back
* up a non-OmniRoute provider before it is overwritten. Extracted to keep POST under
* the complexity gate.
*/
async function prepareLettaAuthFile(
overwrite: boolean | undefined
): Promise<
| { conflictResponse: NextResponse }
| { authFile: { version: number; providers: Record<string, any> }; authPath: string }
> {
const localBackendDir = getLocalBackendDir();
const authPath = getProviderAuthPath();
await fs.mkdir(path.join(localBackendDir, "providers"), { recursive: true });

let authFile = { version: 1, providers: {} as Record<string, any> };
try {
const existing = await fs.readFile(authPath, "utf-8");
authFile = JSON.parse(existing);
} catch {
/* No existing file */
}

const existingProvider = authFile.providers?.[PROVIDER_NAME];
if (existingProvider && !isOmniRouteUrl(existingProvider.base_url) && !overwrite) {
// User has lmstudio configured for actual LM Studio — refuse to overwrite
return {
conflictResponse: NextResponse.json(
{
error: `lmstudio provider is already configured for ${existingProvider.base_url}. Overwriting will break your existing LM Studio connection. Apply again to overwrite.`,
conflict: true,
existingBaseUrl: existingProvider.base_url,
},
{ status: 409 }
),
};
}

// Back up existing lmstudio provider before overwriting
if (existingProvider && !isOmniRouteUrl(existingProvider.base_url)) {
const backupPath = getBackupPath();
await fs.writeFile(backupPath, JSON.stringify(existingProvider, null, 2));
}

return { authFile, authPath };
}

export async function POST(request: Request) {
const authError = await requireCliToolsAuth(request);
if (authError) return authError;
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
}

try {
const validation = validateBody(cliAuthOnlyConfigSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, overwrite } = validation.data;

const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;

// ── 1-2. Read auth.json, guard non-OmniRoute conflicts, back up before overwrite ──
const prepared = await prepareLettaAuthFile(overwrite);
if ("conflictResponse" in prepared) {
return prepared.conflictResponse;
}
const { authFile, authPath } = prepared;

// ── 3. Switch to local mode in settings.json ──
const settingsPath = getSettingsPath();
const lettaDir = getLettaDir();
await fs.mkdir(lettaDir, { recursive: true });

let settings = {};
try {
const existing = await fs.readFile(settingsPath, "utf-8");
settings = JSON.parse(existing);
} catch {
/* No existing settings */
}

settings.preferredBackendMode = "local";
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));

// ── 4. Write lmstudio provider to auth.json ──
// Clean up legacy lc-omniroute provider if present
if (authFile.providers?.["lc-omniroute"]) {
delete authFile.providers["lc-omniroute"];
}

// Create or update lmstudio provider
authFile.providers[PROVIDER_NAME] = {
id: `local-provider-${PROVIDER_NAME}`,
name: PROVIDER_NAME,
provider_type: PROVIDER_TYPE,
provider_category: "byok",
auth: { type: "api", key: apiKey },
base_url: normalizedBaseUrl,
created_at: authFile.providers[PROVIDER_NAME]?.created_at || new Date().toISOString(),
updated_at: new Date().toISOString(),
};

await fs.writeFile(authPath, JSON.stringify(authFile, null, 2));

return NextResponse.json({
success: true,
message: "Settings applied. Restart Letta CLI, then use /model to select a OmniRoute model.",
needsRestart: true,
});
} catch (error) {
return NextResponse.json(
{ error: { message: sanitizeErrorMessage(error) } },
{ status: 500 }
);
}
}

// ── DELETE - Remove OmniRoute configuration ──────────────────────────────
export async function DELETE(request: Request) {
const authError = await requireCliToolsAuth(request);
if (authError) return authError;
try {
// ── 1. Remove lmstudio provider from auth.json, restore backup if exists ──
const authPath = getProviderAuthPath();
const backupPath = getBackupPath();
let authFile = { version: 1, providers: {} };
try {
const existing = await fs.readFile(authPath, "utf-8");
authFile = JSON.parse(existing);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}

let changed = false;
let restored = false;

if (authFile.providers?.[PROVIDER_NAME]) {
// Check if there's a backup of a pre-existing lmstudio config
try {
const backupContent = await fs.readFile(backupPath, "utf-8");
const backupProvider = JSON.parse(backupContent);
// Restore the original lmstudio config
authFile.providers[PROVIDER_NAME] = backupProvider;
restored = true;
await fs.unlink(backupPath);
} catch {
// No backup — just remove the provider
delete authFile.providers[PROVIDER_NAME];
}
changed = true;
}

// Clean up legacy lc-omniroute provider if present
if (authFile.providers?.["lc-omniroute"]) {
delete authFile.providers["lc-omniroute"];
changed = true;
}

if (changed) {
await fs.writeFile(authPath, JSON.stringify(authFile, null, 2));
}

// ── 2. Reset backend mode to api in settings.json ──
const settingsPath = getSettingsPath();
try {
const existing = await fs.readFile(settingsPath, "utf-8");
const settings = JSON.parse(existing);
if (settings.preferredBackendMode === "local") {
settings.preferredBackendMode = "api";
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
}
} catch {
/* No settings file */
}

const message = restored
? "OmniRoute config removed. Your original LM Studio provider has been restored. Restart Letta CLI to take effect."
: "OmniRoute config removed. Restart Letta CLI to take effect.";

return NextResponse.json({
success: true,
message,
needsRestart: true,
});
} catch (error) {
return NextResponse.json(
{ error: { message: sanitizeErrorMessage(error) } },
{ status: 500 }
);
}
}
Loading
Loading