mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
feat(cli): add CLI tools for pi, omp, letta, codewhale and jcode (#6318)
* feat(cli): add CLI tools for pi, omp, letta, codewhale and jcode * fix(build): resolve CI build and lint errors * fix(cli): resolve merge conflicts, add tests, align error handling for cli-additions Resolve duplicate codewhale key from base merge, add unit/integration tests for omp/letta settings routes and the omp DB module, and align omp-settings/letta-settings error handling with sanitizeErrorMessage() + the pattern used by sibling jcode/pi/codewhale routes in this PR. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(quality): correct cliRuntime.ts file-size baseline to actual post-merge line count Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore #6318 bullet after release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(merge): restore #6126 clinepass files reverted by release auto-resolve + baseline re-merge The release sync's auto-resolve reverted sibling PR #6126's clinepass work (registry, catalog, oauth constants, clineAuth.ts, token-refresh case, tests) and the file-size baseline — all outside this PR's scope. Restored to the release versions, re-applied only this PR's own baseline entries, restored the #6126 CHANGELOG bullet (re-inserting only this PR's own). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(db): re-export db/omp from localDb (check:db-rules #2) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(db): keep localDb.ts at the 800-line cap after the omp re-export Folded the MemoryVecMeta type re-export into the memoryVec named-export block (inline 'type' specifier) so adding the db/omp line stays within the new-file cap. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(cli): reduce #6318 scope to omp + letta (pi/codewhale/jcode already shipped) pi, codewhale, and jcode landed via a separate PR before this one was reconciled — re-adding parallel versions of their catalog entries, routes, dashboard card, and i18n strings would have been a straight regression (duplicate "pi" key silently shadowing the release's own entry, orphaned JcodeToolCard/BaseUrlSelect/ApiKeySelect/cliEndpointMatch UI files with no release-side wiring, and unrelated formatting/refactor drift in codewhale-settings/pi-settings/config-generator/routeGuard picked up along the way). This PR now ships only the two tools that are genuinely new: omp (Oh My Pi) and letta. Both settings routes shell out to `which omp`/`which letta` to detect the local install, so they're loopback-gated in LOCAL_ONLY_API_PREFIXES (Hard Rules #15/#17) in addition to the shared requireCliToolsAuth() guard every cli-tools route requires (tests/unit/cli-tools-auth-hardening.test.ts) — neither route had the guard wired in yet. cli-catalog-counts.test.ts is updated to the real cardinality (8 agent entries / 32 total, since omp+letta are both category "agent"; pi/codewhale/jcode were always category "code" and are unaffected). The integration tests for omp/letta now pass a Request object to GET/DELETE and assert the 401-when-auth-required path, matching the pattern already used by the codewhale/jcode sibling routes. complexity-baseline.json is back to the release's 2053 (the #6318 rebaseline note is gone — dropping the duplicate JcodeToolCard.tsx/BaseUrlSelect.tsx removed the violations it was covering); file-size-baseline.json's cliTools.ts entry shrank 955->915 to match the smaller real file. CHANGELOG bullet rewritten to describe only omp+letta, with a note on why pi/codewhale/jcode aren't part of this PR; also restores the Kiro External IdP bullet that a prior merge auto-resolve had dropped from the living section. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(cli-tools): align cli-tools-schema registry count with omp+letta (30→32) Second exact-count guard missed in the scope-reduction pass; same legitimate alignment as cli-catalog-counts. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(cli-tools): omp entry needs docsUrl (CliCatalogEntrySchema requires it) https://github.com/can1357/oh-my-pi — verified official repo. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(quality): cliTools.ts frozen 915→916 (+1 omp docsUrl line, own growth) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(changelog): restore base + re-insert #6318 bullet after release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(sync): merge release tip + restore own CHANGELOG bullet Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: hamsa0x7 <hamsa0x7@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
BIN
public/providers/letta.png
Normal file
BIN
public/providers/letta.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/providers/omp.png
Normal file
BIN
public/providers/omp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
329
src/app/api/cli-tools/letta-settings/route.ts
Normal file
329
src/app/api/cli-tools/letta-settings/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
180
src/app/api/cli-tools/omp-settings/route.ts
Normal file
180
src/app/api/cli-tools/omp-settings/route.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import fs from "fs/promises";
|
||||
import { load as yamlLoad, dump as yamlDump } from "js-yaml";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { cliAuthOnlyConfigSchema } from "@/shared/validation/schemas/cli";
|
||||
import { getOmpCredentials, saveOmpCredentials, deleteOmpCredentials } from "@/lib/db/omp";
|
||||
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const PROVIDER_ID = "omniroute";
|
||||
|
||||
const getOmpDir = () => path.join(os.homedir(), ".omp", "agent");
|
||||
const getOmpDbPath = () => path.join(getOmpDir(), "agent.db");
|
||||
const getOmpModelsYmlPath = () => path.join(getOmpDir(), "models.yml");
|
||||
|
||||
const checkOmpInstalled = async () => {
|
||||
const isWindows = os.platform() === "win32";
|
||||
try {
|
||||
const command = isWindows ? "where omp" : "which omp";
|
||||
await execAsync(command, { windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
await fs.access(getOmpDbPath());
|
||||
return true;
|
||||
} catch {
|
||||
if (isWindows) {
|
||||
try {
|
||||
const appDataPath = path.join(process.env.LOCALAPPDATA || "", "omp", "omp.exe");
|
||||
await fs.access(appDataPath);
|
||||
return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readModelsYml = async () => {
|
||||
try {
|
||||
const content = await fs.readFile(getOmpModelsYmlPath(), "utf-8");
|
||||
return yamlLoad(content) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireCliToolsAuth(request);
|
||||
if (authError) return authError;
|
||||
try {
|
||||
const installed = await checkOmpInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return NextResponse.json({
|
||||
installed: false,
|
||||
config: null,
|
||||
message: "Oh My Pi is not installed",
|
||||
});
|
||||
}
|
||||
|
||||
const creds = getOmpCredentials(PROVIDER_ID);
|
||||
const modelsYml = await readModelsYml();
|
||||
const ymlProvider = modelsYml?.providers?.[PROVIDER_ID];
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
config: {
|
||||
providers: {
|
||||
[PROVIDER_ID]: {
|
||||
baseUrl: ymlProvider?.baseUrl || creds.baseUrl,
|
||||
apiKey: ymlProvider?.apiKey || creds.apiKey,
|
||||
discovery: ymlProvider?.discovery?.type || null,
|
||||
},
|
||||
},
|
||||
},
|
||||
hasOmniRoute: !!(ymlProvider || creds.hasOmniRoute),
|
||||
configPath: getOmpModelsYmlPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: sanitizeErrorMessage(error) } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 } = validation.data;
|
||||
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
const keyRef = apiKey || "sk_omniroute";
|
||||
|
||||
await fs.mkdir(getOmpDir(), { recursive: true });
|
||||
|
||||
// 1. Write models.yml — provider config + auto-discovery
|
||||
const modelsYml = await readModelsYml();
|
||||
if (!modelsYml.providers) modelsYml.providers = {};
|
||||
|
||||
modelsYml.providers[PROVIDER_ID] = {
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey: keyRef,
|
||||
api: "openai-completions",
|
||||
authHeader: true,
|
||||
disableStrictTools: true,
|
||||
discovery: { type: "proxy" },
|
||||
};
|
||||
|
||||
await fs.writeFile(getOmpModelsYmlPath(), yamlDump(modelsYml, { lineWidth: -1 }), "utf-8");
|
||||
|
||||
// 2. Write auth_credentials — so omp sees omniroute as "logged in"
|
||||
saveOmpCredentials(PROVIDER_ID, keyRef, normalizedBaseUrl);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message:
|
||||
"Oh My Pi settings applied! Run omp and all OmniRoute models appear under omniroute in /model.",
|
||||
configPath: getOmpModelsYmlPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: sanitizeErrorMessage(error) } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const authError = await requireCliToolsAuth(request);
|
||||
if (authError) return authError;
|
||||
try {
|
||||
// 1. Remove from models.yml
|
||||
const modelsYml = await readModelsYml();
|
||||
if (modelsYml?.providers?.[PROVIDER_ID]) {
|
||||
delete modelsYml.providers[PROVIDER_ID];
|
||||
if (Object.keys(modelsYml.providers).length === 0) delete modelsYml.providers;
|
||||
await fs.mkdir(getOmpDir(), { recursive: true });
|
||||
if (Object.keys(modelsYml).length === 0) {
|
||||
await fs.unlink(getOmpModelsYmlPath()).catch(() => {});
|
||||
} else {
|
||||
await fs.writeFile(getOmpModelsYmlPath(), yamlDump(modelsYml, { lineWidth: -1 }), "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove from auth_credentials
|
||||
deleteOmpCredentials(PROVIDER_ID);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "OmniRoute removed from Oh My Pi",
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: sanitizeErrorMessage(error) } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
52
src/lib/db/omp.ts
Normal file
52
src/lib/db/omp.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const getOmpDir = () => path.join(os.homedir(), ".omp", "agent");
|
||||
const getOmpDbPath = () => path.join(getOmpDir(), "agent.db");
|
||||
|
||||
export function getOmpCredentials(providerId: string) {
|
||||
const dbPath = getOmpDbPath();
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT data FROM auth_credentials WHERE provider = ? AND credential_type = 'api_key'"
|
||||
)
|
||||
.get(providerId) as { data: string } | undefined;
|
||||
db.close();
|
||||
|
||||
if (row?.data) {
|
||||
const parsed = JSON.parse(row.data);
|
||||
return { hasOmniRoute: true, baseUrl: parsed.baseUrl || null, apiKey: parsed.apiKey || null };
|
||||
}
|
||||
return { hasOmniRoute: false, baseUrl: null, apiKey: null };
|
||||
} catch {
|
||||
return { hasOmniRoute: false, baseUrl: null, apiKey: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveOmpCredentials(providerId: string, apiKey: string, baseUrl: string) {
|
||||
const dbPath = getOmpDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.prepare("DELETE FROM auth_credentials WHERE provider = ?").run(providerId);
|
||||
db.prepare(
|
||||
"INSERT INTO auth_credentials (provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?, ?)"
|
||||
).run(
|
||||
providerId,
|
||||
"api_key",
|
||||
JSON.stringify({ apiKey, baseUrl }),
|
||||
Math.floor(Date.now() / 1000),
|
||||
Math.floor(Date.now() / 1000)
|
||||
);
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
export function deleteOmpCredentials(providerId: string) {
|
||||
const dbPath = getOmpDbPath();
|
||||
const db = new Database(dbPath);
|
||||
db.prepare("DELETE FROM auth_credentials WHERE provider = ?").run(providerId);
|
||||
db.close();
|
||||
}
|
||||
@@ -589,15 +589,15 @@ export {
|
||||
markAllMemoriesNeedReindex,
|
||||
getMemoryReindexQueue,
|
||||
countMemoryReindexPending,
|
||||
type MemoryVecMeta,
|
||||
} from "./db/memoryVec";
|
||||
|
||||
export type { MemoryVecMeta } from "./db/memoryVec";
|
||||
// T-A-F2: AgentBridge state/mappings/bypass + Inspector custom hosts/sessions
|
||||
export * from "./db/agentBridgeState";
|
||||
export * from "./db/agentBridgeMappings";
|
||||
export * from "./db/agentBridgeBypass";
|
||||
export * from "./db/inspectorCustomHosts";
|
||||
export * from "./db/inspectorSessions";
|
||||
export * from "./db/omp";
|
||||
// Quota Sharing — Group B (planos 16+22)
|
||||
export {
|
||||
listPools,
|
||||
|
||||
@@ -29,6 +29,8 @@ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
"/api/mcp/",
|
||||
"/api/cli-tools/runtime/",
|
||||
"/api/cli-tools/omp-settings", // spawns `which omp` to detect the CLI install (Hard Rules #15 + #17, #6318)
|
||||
"/api/cli-tools/letta-settings", // spawns `which letta` to detect the CLI install (Hard Rules #15 + #17, #6318)
|
||||
"/api/services/", // T-10: embedded service lifecycle (spawn child processes)
|
||||
"/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs
|
||||
"/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass
|
||||
|
||||
@@ -796,6 +796,59 @@ OPENAI_API_KEY: "{{apiKey}}"`,
|
||||
},
|
||||
},
|
||||
|
||||
omp: {
|
||||
id: "omp",
|
||||
name: "Oh My Pi",
|
||||
image: "/providers/omp.png",
|
||||
color: "#111111",
|
||||
docsUrl: "https://github.com/can1357/oh-my-pi",
|
||||
description: "Oh My Pi terminal coding agent via OmniRoute",
|
||||
configType: "custom",
|
||||
category: "agent",
|
||||
vendor: "OSS",
|
||||
acpSpawnable: true,
|
||||
baseUrlSupport: "full",
|
||||
defaultCommand: "omp",
|
||||
notes: [
|
||||
{
|
||||
type: "info",
|
||||
text: "Oh My Pi reads custom OpenAI-compatible providers from ~/.omp/agent/models.yml. OmniRoute adds itself as a provider with auto-discovery — models appear automatically in omp's /model menu.",
|
||||
},
|
||||
{
|
||||
type: "warning",
|
||||
text: "Config path: Linux/macOS ~/.omp/agent/models.yml • Windows %USERPROFILE%\\.omp\\.omp\\agent\\models.yml",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
letta: {
|
||||
id: "letta",
|
||||
name: "Letta CLI",
|
||||
image: "/providers/letta.png",
|
||||
color: "#FF6B35",
|
||||
description: "Letta CLI — AI agent with persistent memory and tool use",
|
||||
configType: "custom",
|
||||
category: "agent",
|
||||
vendor: "Letta",
|
||||
acpSpawnable: false,
|
||||
baseUrlSupport: "full",
|
||||
docsUrl: "https://docs.letta.com",
|
||||
notes: [
|
||||
{
|
||||
type: "info",
|
||||
text: "Letta CLI uses pi-ai which sends OpenAI-compatible requests. OmniRoute configures it as an OpenAI provider with custom base URL.",
|
||||
},
|
||||
{
|
||||
type: "info",
|
||||
text: "CLI (Local Mode): OmniRoute auto-configures ~/.letta/lc-local-backend/providers/auth.json. Use 'letta --info' to check if local mode is enabled.",
|
||||
},
|
||||
{
|
||||
type: "warning",
|
||||
text: "Local mode config path: ~/.letta/lc-local-backend/providers/auth.json (CLI only)",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
/** ★ Added by plan 14 (CLI Pages Redesign) — 2026-05-27 */
|
||||
warp: {
|
||||
id: "warp",
|
||||
|
||||
@@ -63,4 +63,7 @@ export const CliCatalogSchema = z.record(CliCatalogEntrySchema);
|
||||
// +1 (2026-07-02): "codewhale" added as a dual entry alongside "deepseek-tui"
|
||||
// (CodeWhale is the actively-maintained successor to DeepSeek TUI).
|
||||
export const EXPECTED_CODE_COUNT = 20;
|
||||
export const EXPECTED_AGENT_COUNT = 6;
|
||||
// +2 (#6318): "omp" (Oh My Pi) and "letta" (Letta CLI) added as agent entries.
|
||||
// Note: #6318 originally also shipped duplicate "pi"/"jcode"/"codewhale" entries —
|
||||
// those tools were already delivered by a separate PR, so only omp+letta landed here.
|
||||
export const EXPECTED_AGENT_COUNT = 8;
|
||||
|
||||
@@ -210,6 +210,24 @@ const CLI_TOOLS: Record<string, any> = {
|
||||
config: ".config/deepseek-tui/config.toml",
|
||||
},
|
||||
},
|
||||
omp: {
|
||||
defaultCommand: "omp",
|
||||
envBinKey: "CLI_OMP_BIN",
|
||||
requiresBinary: true,
|
||||
healthcheckTimeoutMs: 8000,
|
||||
paths: {
|
||||
config: ".omp/agent/models.yml",
|
||||
},
|
||||
},
|
||||
letta: {
|
||||
defaultCommand: "letta",
|
||||
envBinKey: "CLI_LETTA_BIN",
|
||||
requiresBinary: true,
|
||||
healthcheckTimeoutMs: 8000,
|
||||
paths: {
|
||||
config: ".letta/lc-local-backend/providers/auth.json",
|
||||
},
|
||||
},
|
||||
codewhale: {
|
||||
defaultCommand: "codewhale",
|
||||
envBinKey: "CLI_CODEWHALE_BIN",
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "@/shared/constants/upstreamHeaders";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
|
||||
|
||||
|
||||
export const cliMitmStartSchema = z.object({
|
||||
apiKey: z.string().trim().min(1).nullable().optional(),
|
||||
keyId: z.string().trim().min(1).nullable().optional(),
|
||||
@@ -83,4 +82,10 @@ export const cliModelConfigSchema = z.object({
|
||||
export const cliMultiModelConfigSchema = cliModelConfigSchema.extend({
|
||||
models: z.array(z.string().trim().min(1)).optional(),
|
||||
activeModel: z.string().optional(),
|
||||
});
|
||||
});
|
||||
|
||||
export const cliAuthOnlyConfigSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1, "baseUrl is required"),
|
||||
apiKey: z.string().nullable().optional(),
|
||||
overwrite: z.boolean().optional(),
|
||||
});
|
||||
|
||||
196
tests/integration/cli-settings-letta.test.ts
Normal file
196
tests/integration/cli-settings-letta.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Integration tests for /api/cli-tools/letta-settings
|
||||
*
|
||||
* Letta configures OmniRoute as its "lmstudio" provider (localModelDiscovery:
|
||||
* openai-compatible auto-discovers models from /v1/models). The route shells
|
||||
* out to `which letta` to detect the CLI install, so it is classified
|
||||
* local-only in routeGuard.ts (Hard Rules #15 + #17) AND guarded by
|
||||
* requireCliToolsAuth() like every other cli-tools route
|
||||
* (tests/unit/cli-tools-auth-hardening.test.ts).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-letta-settings-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret-letta";
|
||||
process.env.JWT_SECRET = "test-jwt-secret-letta";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
|
||||
const { GET, POST, DELETE } = await import(
|
||||
"../../src/app/api/cli-tools/letta-settings/route.ts"
|
||||
);
|
||||
|
||||
let tmpHome: string;
|
||||
let origHome: string | undefined;
|
||||
|
||||
function getAuthPath() {
|
||||
return path.join(tmpHome, ".letta", "lc-local-backend", "providers", "auth.json");
|
||||
}
|
||||
|
||||
function req(init?: RequestInit) {
|
||||
return new Request("http://localhost/api/cli-tools/letta-settings", init);
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function enableAuth() {
|
||||
process.env.INITIAL_PASSWORD = "test-bootstrap";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "letta-settings-home-"));
|
||||
origHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Test 1: GET without auth → 401 ──────────────────────────────────────────
|
||||
|
||||
test("letta-settings GET: returns 401 when auth required and no token", async () => {
|
||||
await enableAuth();
|
||||
const res = await GET(req());
|
||||
assert.equal(res.status, 401, `Expected 401, got ${res.status}`);
|
||||
});
|
||||
|
||||
// ── Test 2: GET → 200 installed:false when the letta CLI is absent ──────────
|
||||
|
||||
test("letta-settings GET: returns 200 installed:false when Letta CLI is absent", async () => {
|
||||
const res = await GET(req());
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.equal(body.installed, false);
|
||||
assert.equal(body.config, null);
|
||||
});
|
||||
|
||||
// ── Test 3: GET → detects "installed" via an existing ~/.letta dir ──────────
|
||||
|
||||
test("letta-settings GET: treats an existing ~/.letta directory as installed", async () => {
|
||||
fs.mkdirSync(path.join(tmpHome, ".letta"), { recursive: true });
|
||||
const res = await GET(req());
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.installed, true);
|
||||
assert.equal(body.hasOmniRoute, false);
|
||||
});
|
||||
|
||||
// ── Test 4: POST with invalid body → 400 ─────────────────────────────────────
|
||||
|
||||
test("letta-settings POST: 400 when baseUrl is missing", async () => {
|
||||
const res = await POST(
|
||||
req({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ apiKey: "sk-test" }),
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 400, `Expected 400, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error !== undefined);
|
||||
});
|
||||
|
||||
// ── Test 5: POST with valid body → writes the lmstudio provider to auth.json ─
|
||||
|
||||
test("letta-settings POST: writes the lmstudio provider entry for a fresh install", async () => {
|
||||
const res = await POST(
|
||||
req({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test-letta" }),
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
|
||||
const authPath = getAuthPath();
|
||||
assert.ok(fs.existsSync(authPath), "auth.json must be written");
|
||||
const authFile = JSON.parse(fs.readFileSync(authPath, "utf-8"));
|
||||
assert.equal(authFile.providers.lmstudio.base_url, "http://localhost:20128/v1");
|
||||
assert.equal(authFile.providers.lmstudio.auth.key, "sk-test-letta");
|
||||
});
|
||||
|
||||
// ── Test 6: POST refuses to overwrite an existing non-OmniRoute lmstudio config ──
|
||||
|
||||
test("letta-settings POST: 409 when lmstudio is already configured for real LM Studio", async () => {
|
||||
const providersDir = path.join(tmpHome, ".letta", "lc-local-backend", "providers");
|
||||
fs.mkdirSync(providersDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(providersDir, "auth.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
providers: { lmstudio: { base_url: "http://localhost:1234/v1" } },
|
||||
})
|
||||
);
|
||||
|
||||
const res = await POST(
|
||||
req({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test-letta" }),
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 409, `Expected 409, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.equal(body.conflict, true);
|
||||
});
|
||||
|
||||
// ── Test 7: DELETE → removes the OmniRoute lmstudio config ──────────────────
|
||||
|
||||
test("letta-settings DELETE: removes the lmstudio provider written by POST", async () => {
|
||||
await POST(
|
||||
req({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test-letta" }),
|
||||
})
|
||||
);
|
||||
|
||||
const res = await DELETE(req({ method: "DELETE" }));
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
|
||||
const authFile = JSON.parse(fs.readFileSync(getAuthPath(), "utf-8"));
|
||||
assert.ok(!authFile.providers.lmstudio, "lmstudio provider must be removed");
|
||||
});
|
||||
|
||||
// ── Test 8: Error sanitization (Hard Rule #12) ───────────────────────────────
|
||||
|
||||
test("letta-settings: error responses do not leak stack traces", async () => {
|
||||
const badReq = req({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{ bad json }",
|
||||
});
|
||||
const res = await POST(badReq);
|
||||
const bodyStr = JSON.stringify(await res.json());
|
||||
assert.ok(
|
||||
!bodyStr.match(/\s+at\s+\/[^\s]/),
|
||||
"Error response must not contain absolute-path stack traces"
|
||||
);
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.JWT_SECRET;
|
||||
});
|
||||
197
tests/integration/cli-settings-omp.test.ts
Normal file
197
tests/integration/cli-settings-omp.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Integration tests for /api/cli-tools/omp-settings
|
||||
*
|
||||
* Oh My Pi (omp) reads its own local sqlite DB (~/.omp/agent/agent.db,
|
||||
* created by the omp CLI itself) via src/lib/db/omp.ts, plus a
|
||||
* ~/.omp/agent/models.yml file for provider/model discovery config. The route
|
||||
* shells out to `which omp` to detect the CLI install, so it is classified
|
||||
* local-only in routeGuard.ts (Hard Rules #15 + #17) AND guarded by
|
||||
* requireCliToolsAuth() like every other cli-tools route
|
||||
* (tests/unit/cli-tools-auth-hardening.test.ts).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-omp-settings-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret-omp";
|
||||
process.env.JWT_SECRET = "test-jwt-secret-omp";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
|
||||
const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/omp-settings/route.ts");
|
||||
|
||||
let tmpHome: string;
|
||||
let origHome: string | undefined;
|
||||
|
||||
function getOmpDir() {
|
||||
return path.join(tmpHome, ".omp", "agent");
|
||||
}
|
||||
|
||||
function req(init?: RequestInit) {
|
||||
return new Request("http://localhost/api/cli-tools/omp-settings", init);
|
||||
}
|
||||
|
||||
/** Simulate the omp CLI having already created its sqlite DB + schema. */
|
||||
function seedOmpDb() {
|
||||
const dbPath = path.join(getOmpDir(), "agent.db");
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS auth_credentials (
|
||||
provider TEXT NOT NULL,
|
||||
credential_type TEXT NOT NULL,
|
||||
data TEXT,
|
||||
disabled_cause TEXT,
|
||||
identity_key TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)
|
||||
`);
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function enableAuth() {
|
||||
process.env.INITIAL_PASSWORD = "test-bootstrap";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omp-settings-home-"));
|
||||
origHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Test 1: GET without auth → 401 ──────────────────────────────────────────
|
||||
|
||||
test("omp-settings GET: returns 401 when auth required and no token", async () => {
|
||||
await enableAuth();
|
||||
const res = await GET(req());
|
||||
assert.equal(res.status, 401, `Expected 401, got ${res.status}`);
|
||||
});
|
||||
|
||||
// ── Test 2: GET → 200 with installed:false when omp is not present ──────────
|
||||
|
||||
test("omp-settings GET: returns 200 installed:false when omp CLI and DB are both absent", async () => {
|
||||
const res = await GET(req());
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.equal(body.installed, false);
|
||||
assert.equal(body.config, null);
|
||||
});
|
||||
|
||||
// ── Test 3: GET → detects "installed" via the DB file even without the binary on PATH ──
|
||||
|
||||
test("omp-settings GET: treats an existing agent.db as installed", async () => {
|
||||
seedOmpDb();
|
||||
const res = await GET(req());
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.installed, true);
|
||||
assert.equal(body.hasOmniRoute, false);
|
||||
});
|
||||
|
||||
// ── Test 4: POST with invalid body → 400 ─────────────────────────────────────
|
||||
|
||||
test("omp-settings POST: 400 when baseUrl is missing", async () => {
|
||||
const res = await POST(
|
||||
req({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ apiKey: "sk-test" }),
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 400, `Expected 400, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error !== undefined);
|
||||
});
|
||||
|
||||
// ── Test 5: POST with valid body → writes models.yml + persists credentials ──
|
||||
|
||||
test("omp-settings POST: writes models.yml and persists credentials for a seeded DB", async () => {
|
||||
seedOmpDb();
|
||||
|
||||
const res = await POST(
|
||||
req({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test-omp" }),
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
|
||||
const modelsYmlPath = path.join(getOmpDir(), "models.yml");
|
||||
assert.ok(fs.existsSync(modelsYmlPath), "models.yml must be written");
|
||||
const content = fs.readFileSync(modelsYmlPath, "utf-8");
|
||||
assert.ok(content.includes("http://localhost:20128/v1"), "models.yml must contain the base URL");
|
||||
|
||||
const getRes = await GET(req());
|
||||
const getBody = await getRes.json();
|
||||
assert.equal(getBody.hasOmniRoute, true);
|
||||
});
|
||||
|
||||
// ── Test 6: DELETE → removes OmniRoute provider entry ────────────────────────
|
||||
|
||||
test("omp-settings DELETE: removes the OmniRoute provider from models.yml and credentials", async () => {
|
||||
seedOmpDb();
|
||||
await POST(
|
||||
req({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test-omp" }),
|
||||
})
|
||||
);
|
||||
|
||||
const res = await DELETE(req({ method: "DELETE" }));
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
|
||||
const getRes = await GET(req());
|
||||
const getBody = await getRes.json();
|
||||
assert.equal(getBody.hasOmniRoute, false);
|
||||
});
|
||||
|
||||
// ── Test 7: Error sanitization (Hard Rule #12) ───────────────────────────────
|
||||
|
||||
test("omp-settings: error responses do not leak stack traces", async () => {
|
||||
const badReq = req({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{ bad json }",
|
||||
});
|
||||
const res = await POST(badReq);
|
||||
const bodyStr = JSON.stringify(await res.json());
|
||||
assert.ok(
|
||||
!bodyStr.match(/\s+at\s+\/[^\s]/),
|
||||
"Error response must not contain absolute-path stack traces"
|
||||
);
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.JWT_SECRET;
|
||||
});
|
||||
@@ -41,8 +41,8 @@ test("CLI_TOOLS total code entries (including none) equals 24 (20 visible + 4 no
|
||||
assert.equal(codeAll.length, 24, `Expected 24 total code entries, got ${codeAll.length}`);
|
||||
});
|
||||
|
||||
test("CLI_TOOLS total (code + agent) = 30", () => {
|
||||
assert.equal(all.length, 30, `Expected 30 total entries, got ${all.length}`);
|
||||
test("CLI_TOOLS total (code + agent) = 32", () => {
|
||||
assert.equal(all.length, 32, `Expected 32 total entries, got ${all.length}`);
|
||||
});
|
||||
|
||||
test("All code-none entries have configType mitm OR are legacy excluded entries", () => {
|
||||
@@ -98,7 +98,7 @@ test("The 20 visible code entries match D15 list exactly (+ crush + codewhale)",
|
||||
}
|
||||
});
|
||||
|
||||
test("The 6 agent entries match D15 list exactly", () => {
|
||||
test("The 8 agent entries match D15 list exactly (+ omp + letta, #6318)", () => {
|
||||
const d15Agents = new Set([
|
||||
"hermes-agent",
|
||||
"openclaw",
|
||||
@@ -106,6 +106,8 @@ test("The 6 agent entries match D15 list exactly", () => {
|
||||
"interpreter",
|
||||
"warp",
|
||||
"agent-deck",
|
||||
"omp",
|
||||
"letta",
|
||||
]);
|
||||
const agentIds = new Set(agentAll.map((t) => t.id));
|
||||
for (const id of d15Agents) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
test("CLI_TOOLS registry contains all expected tools (plan 14 — 30 total + crush + codewhale)", async () => {
|
||||
test("CLI_TOOLS registry contains all expected tools (plan 14 — 32 total + crush + codewhale + omp + letta)", async () => {
|
||||
const { CLI_TOOLS } = await import("../../src/shared/constants/cliTools.ts");
|
||||
// windsurf and amp removed per plan 14 D17 (MITM backlog plan 11)
|
||||
// New entries added: roo, jcode, deepseek-tui, smelt, pi, aider, forge,
|
||||
@@ -9,6 +9,7 @@ test("CLI_TOOLS registry contains all expected tools (plan 14 — 30 total + cru
|
||||
// crush added — ported from upstream decolua/9router#1233
|
||||
// codewhale added 2026-07-02 as a dual entry alongside deepseek-tui
|
||||
// (CodeWhale is the actively-maintained successor to DeepSeek TUI).
|
||||
// omp + letta added by #6318 (agent-category CLI integrations).
|
||||
const expected = [
|
||||
"claude",
|
||||
"codex",
|
||||
@@ -38,6 +39,8 @@ test("CLI_TOOLS registry contains all expected tools (plan 14 — 30 total + cru
|
||||
"goose",
|
||||
"interpreter",
|
||||
"warp",
|
||||
"omp",
|
||||
"letta",
|
||||
"agent-deck",
|
||||
"crush",
|
||||
];
|
||||
|
||||
140
tests/unit/db/omp.test.ts
Normal file
140
tests/unit/db/omp.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Unit tests for src/lib/db/omp.ts — OMP (Oh My Pi) credential CRUD.
|
||||
*
|
||||
* omp.ts opens the third-party OMP CLI's OWN local sqlite database
|
||||
* (~/.omp/agent/agent.db) directly, per request — NOT OmniRoute's own DB.
|
||||
* These tests cover both the happy path (round trip against a fixture DB
|
||||
* with the omp CLI's real `auth_credentials` schema) and the missing-DB-file
|
||||
* path (omp CLI never run yet), which each exported function must handle
|
||||
* gracefully without throwing.
|
||||
*/
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const {
|
||||
getOmpCredentials,
|
||||
saveOmpCredentials,
|
||||
deleteOmpCredentials,
|
||||
} = await import("../../../src/lib/db/omp.ts");
|
||||
|
||||
const PROVIDER_ID = "omniroute";
|
||||
|
||||
let tmpHome: string;
|
||||
let origHome: string | undefined;
|
||||
|
||||
function getOmpDbPath() {
|
||||
return path.join(tmpHome, ".omp", "agent", "agent.db");
|
||||
}
|
||||
|
||||
/** Simulate the omp CLI having already created its sqlite DB + schema. */
|
||||
function seedOmpDb() {
|
||||
const dbPath = getOmpDbPath();
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS auth_credentials (
|
||||
provider TEXT NOT NULL,
|
||||
credential_type TEXT NOT NULL,
|
||||
data TEXT,
|
||||
disabled_cause TEXT,
|
||||
identity_key TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)
|
||||
`);
|
||||
db.close();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omp-db-test-"));
|
||||
origHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("db/omp.ts — getOmpCredentials", () => {
|
||||
it("returns hasOmniRoute:false without throwing when the omp DB file does not exist", () => {
|
||||
assert.ok(!fs.existsSync(getOmpDbPath()), "precondition: no DB file yet");
|
||||
const creds = getOmpCredentials(PROVIDER_ID);
|
||||
assert.deepEqual(creds, { hasOmniRoute: false, baseUrl: null, apiKey: null });
|
||||
});
|
||||
|
||||
it("returns hasOmniRoute:false when the DB exists but has no matching row", () => {
|
||||
seedOmpDb();
|
||||
const creds = getOmpCredentials(PROVIDER_ID);
|
||||
assert.deepEqual(creds, { hasOmniRoute: false, baseUrl: null, apiKey: null });
|
||||
});
|
||||
|
||||
it("returns hasOmniRoute:false gracefully when the schema itself is missing (corrupt/foreign DB)", () => {
|
||||
const dbPath = getOmpDbPath();
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
// Valid sqlite file, but no auth_credentials table at all.
|
||||
const db = new Database(dbPath);
|
||||
db.exec("CREATE TABLE unrelated (id INTEGER)");
|
||||
db.close();
|
||||
|
||||
const creds = getOmpCredentials(PROVIDER_ID);
|
||||
assert.deepEqual(creds, { hasOmniRoute: false, baseUrl: null, apiKey: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("db/omp.ts — saveOmpCredentials + getOmpCredentials round trip", () => {
|
||||
it("persists apiKey/baseUrl so a subsequent read sees them", () => {
|
||||
seedOmpDb();
|
||||
|
||||
saveOmpCredentials(PROVIDER_ID, "sk-test-omp-key", "http://localhost:20128/v1");
|
||||
|
||||
const creds = getOmpCredentials(PROVIDER_ID);
|
||||
assert.equal(creds.hasOmniRoute, true);
|
||||
assert.equal(creds.apiKey, "sk-test-omp-key");
|
||||
assert.equal(creds.baseUrl, "http://localhost:20128/v1");
|
||||
});
|
||||
|
||||
it("overwrites an existing row for the same provider instead of duplicating it", () => {
|
||||
seedOmpDb();
|
||||
|
||||
saveOmpCredentials(PROVIDER_ID, "sk-old-key", "http://localhost:20128/v1");
|
||||
saveOmpCredentials(PROVIDER_ID, "sk-new-key", "http://localhost:20129/v1");
|
||||
|
||||
const dbPath = getOmpDbPath();
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const rows = db
|
||||
.prepare("SELECT data FROM auth_credentials WHERE provider = ?")
|
||||
.all(PROVIDER_ID) as { data: string }[];
|
||||
db.close();
|
||||
|
||||
assert.equal(rows.length, 1, "must not accumulate duplicate rows for the same provider");
|
||||
const parsed = JSON.parse(rows[0].data);
|
||||
assert.equal(parsed.apiKey, "sk-new-key");
|
||||
assert.equal(parsed.baseUrl, "http://localhost:20129/v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("db/omp.ts — deleteOmpCredentials", () => {
|
||||
it("removes the row so a subsequent get reports hasOmniRoute:false", () => {
|
||||
seedOmpDb();
|
||||
saveOmpCredentials(PROVIDER_ID, "sk-test-omp-key", "http://localhost:20128/v1");
|
||||
assert.equal(getOmpCredentials(PROVIDER_ID).hasOmniRoute, true);
|
||||
|
||||
deleteOmpCredentials(PROVIDER_ID);
|
||||
|
||||
assert.deepEqual(getOmpCredentials(PROVIDER_ID), {
|
||||
hasOmniRoute: false,
|
||||
baseUrl: null,
|
||||
apiKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not throw when deleting a provider that was never saved", () => {
|
||||
seedOmpDb();
|
||||
assert.doesNotThrow(() => deleteOmpCredentials(PROVIDER_ID));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user