Compare commits

...

10 Commits

Author SHA1 Message Date
diegosouzapw
cbd60c853e feat(release): v2.0.10 — CLI fingerprint UI toggle 2026-03-07 10:57:16 -03:00
diegosouzapw
2d8091340f feat: CLI fingerprint UI toggle + fix playground i18n (#223)
- Add CLI Fingerprint Matching card in Settings > Security tab
  - Per-provider toggle chips (codex, claude, github, antigravity)
  - Emerald-themed UI with fingerprint icon and active count
  - Settings synced to runtime cache via API
- Fix playground i18n missing from 29 non-English sidebar translations
- Add cliCompatProviders to settings schema (Zod validation)
- Add setCliCompatProviders/getCliCompatProviders to cliFingerprints.ts
- Add i18n keys for CLI fingerprint settings (en + pt-BR)
2026-03-07 10:56:58 -03:00
diegosouzapw
2025c16c82 fix: deploy workflow uses node app/server.js for pm2 2026-03-07 10:40:00 -03:00
diegosouzapw
811fb7f9b2 docs: fix deploy workflow to use npm-only approach
Old workflow used git clone at /opt/omniroute-app but recent releases
used npm install -g, causing version mismatch. PM2 now runs from
/usr/lib/node_modules/omniroute directly. Removed all references to
the stale local directory.
2026-03-07 10:38:49 -03:00
diegosouzapw
a2bd32e76c feat(release): v2.0.9 — playground, CLI fingerprints, ACP 2026-03-07 10:26:34 -03:00
Diego Rodrigues de Sa e Souza
89c07f4e8a Merge pull request #240 from diegosouzapw/feat/all-features-234-223-235
feat: playground, CLI fingerprints, ACP support (#234, #223, #235)
2026-03-07 10:25:28 -03:00
diegosouzapw
ac89069671 feat: playground, CLI fingerprints, ACP support (#234, #223, #235)
- Add model playground page to dashboard (provider/model/endpoint selectors, Monaco editor, streaming, abort)
- Add CLI fingerprint matching (per-provider header/body ordering to match native CLI binaries)
- Add ACP module (CLI agent discovery, process spawner, session manager, API endpoint)
- Add playground to sidebar debug section with i18n support
- Close #192 and #200 as stale (v1.8.1, no repro info)
2026-03-07 10:24:43 -03:00
diegosouzapw
7cb420d8e6 feat(release): v2.0.8 — custom image model handler resolution 2026-03-07 10:05:20 -03:00
Diego Rodrigues de Sa e Souza
d3919d441f Merge pull request #239 from diegosouzapw/fix/issue-238-image-handler
fix: pass resolved provider to image handler for custom models (#238)
2026-03-07 10:04:24 -03:00
diegosouzapw
4b5824babc fix: pass resolved provider to image handler for custom models (#238) 2026-03-07 10:03:48 -03:00
47 changed files with 1347 additions and 63 deletions

View File

@@ -4,52 +4,73 @@ description: Deploy the latest OmniRoute code to the Akamai VPS (69.164.221.35)
# Deploy to VPS Workflow
Deploy OmniRoute to the production VPS using Node.js + PM2 (no Docker).
Deploy OmniRoute to the production VPS using `npm install -g` + PM2.
**VPS:** `69.164.221.35` (Akamai, Ubuntu 24.04, 1GB RAM + 2.5GB swap)
**App path:** `/opt/omniroute-app`
**Local VPS:** `192.168.0.15` (same setup)
**Process manager:** PM2 (`omniroute`)
**Port:** `20128`
> [!IMPORTANT]
> PM2 runs from the global npm package at `/usr/lib/node_modules/omniroute`.
> **DO NOT** use git clone or local copies. The `npm install -g` command handles
> building, publishing, and installing the standalone app in one step.
## Steps
### 1. Push to GitHub
### 1. Publish to npm
Ensure all changes are committed and pushed:
Ensure the version in `package.json` is bumped and the package is published:
```bash
git push origin main
npm publish
```
### 2. SSH into VPS, pull latest code, rebuild, and restart
### 2. Install on VPS and restart PM2
// turbo-all
```bash
ssh root@69.164.221.35 "
cd /opt/omniroute-app &&
git fetch origin &&
git reset --hard origin/main &&
export NODE_OPTIONS='--max-old-space-size=1536' &&
npm install --no-audit --no-fund &&
npm run build &&
pm2 restart omniroute &&
pm2 save &&
echo '✅ Deploy complete!'
"
ssh root@69.164.221.35 "npm install -g omniroute@latest && pm2 restart omniroute && pm2 save && echo '✅ Deploy complete!'"
```
For the local VPS:
```bash
ssh root@192.168.0.15 "npm install -g omniroute@latest && pm2 restart omniroute && pm2 save && echo '✅ Deploy complete!'"
```
### 3. Verify the deployment
```bash
ssh root@69.164.221.35 "pm2 list && curl -s -o /dev/null -w 'HTTP %{http_code}' http://localhost:20128/"
ssh root@69.164.221.35 "pm2 list && cat \$(npm root -g)/omniroute/package.json | grep version | head -1 && curl -s -o /dev/null -w 'HTTP %{http_code}' http://localhost:20128/"
```
Expected: PM2 shows `online`, HTTP returns `307` (redirect to login).
Expected: PM2 shows `online`, version matches published, HTTP returns `307` (redirect to login).
## How it works
1. `npm publish` builds Next.js standalone + bundles everything into the npm package
2. `npm install -g omniroute@latest` downloads and installs to `/usr/lib/node_modules/omniroute/`
3. PM2 is registered to run `npm start` from that directory (cwd: `/usr/lib/node_modules/omniroute`)
4. `pm2 restart omniroute` picks up the new code immediately
## PM2 Setup (one-time)
If PM2 needs to be reconfigured from scratch:
```bash
ssh root@<VPS> "
cd /usr/lib/node_modules/omniroute &&
PORT=20128 pm2 start app/server.js --name omniroute --env PORT=20128 &&
pm2 save &&
pm2 startup
"
```
## Notes
- The VPS has only 1GB RAM. `NODE_OPTIONS='--max-old-space-size=1536'` uses swap for the build.
- The `.env` file is at `/usr/lib/node_modules/omniroute/.env`. Back it up before major npm updates.
- PM2 is configured with `pm2 startup` to auto-restart on reboot.
- The `.env` file is at `/opt/omniroute-app/.env` (copied from the old Docker setup at `/opt/omniroute/.env`).
- Nginx proxies `omniroute.online``localhost:20128`.
- The VPS has only 1GB RAM — builds happen locally via `npm publish`, not on the VPS.

View File

@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [2.0.9] — 2026-03-07
> ### 🚀 Feature Drop — Playground, CLI Fingerprints, ACP
### ✨ New Features
- **#234 — Model Playground** — Dashboard page to test any model directly (provider/model/endpoint selectors, Monaco Editor, streaming, abort, timing metrics). Available in the Debug sidebar section.
- **#223 — CLI Fingerprint Matching** — Per-provider header/body field ordering to match native CLI binary fingerprints, reducing account flagging risk. Enable via `CLI_COMPAT_<PROVIDER>=1` or `CLI_COMPAT_ALL=1` env vars.
- **#235 — ACP Support** — Agent Client Protocol module with CLI agent discovery (Codex, Claude, Goose, Gemini CLI, OpenClaw), process spawner/manager, and `/api/acp/agents` endpoint.
### 🧹 Housekeeping
- **#192 & #200** — Closed as stale (needs-info, v1.8.1, no reproduction info provided)
---
## [2.0.8] — 2026-03-07
> ### 🐛 Bug Fix — Custom Image Model Handler Resolution
### 🐛 Bug Fixes
- **#238 — Custom image models still fail in handler layer** — v2.0.7 fixed the route-layer validation, but the handler (`handleImageGeneration()`) called `parseImageModel()` again internally, rejecting custom models a second time. Fix: handler now accepts an optional `resolvedProvider` parameter; when provided, it skips re-validation and routes custom models to the OpenAI-compatible handler with a synthetic config. PR #239
### 📁 Files Changed
| File | Change |
| -------------------------------------------- | -------------------------------------------------------------------------------- |
| `open-sse/handlers/imageGeneration.ts` | Added `resolvedProvider` param + custom model fallback |
| `src/app/api/v1/images/generations/route.ts` | Tracks `isCustomModel`, passes `resolvedProvider`, credentials for custom models |
---
## [2.0.7] — 2026-03-07
> ### 🐛 Bug Fixes — Custom Image Models + Codex OAuth Workspace Isolation

View File

@@ -0,0 +1,264 @@
/**
* CLI Fingerprint Definitions
*
* Defines per-provider "fingerprints" that control the exact ordering of HTTP headers
* and JSON body fields to match the native CLI tools exactly.
*
* When `cliCompatMode` is enabled for a provider, OmniRoute reorders outgoing requests
* to be indistinguishable from the real CLI binary, reducing account flagging risk.
*
* Header order and body field order were captured via mitmproxy traffic analysis.
*/
export interface CliFingerprint {
/** Ordered list of header names (case-sensitive). Unlisted headers are appended. */
headerOrder: string[];
/** Ordered list of top-level JSON body fields. Unlisted fields are appended. */
bodyFieldOrder: string[];
/** User-Agent string to inject (overrides default) */
userAgent?: string;
/** Extra headers to add */
extraHeaders?: Record<string, string>;
}
/**
* Fingerprint registry - keyed by provider alias (lowercase).
* Based on mitmproxy traffic captures from native CLI tools.
*/
export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
codex: {
headerOrder: [
"Host",
"Content-Type",
"Authorization",
"Accept",
"User-Agent",
"Accept-Encoding",
],
bodyFieldOrder: [
"model",
"messages",
"temperature",
"top_p",
"max_tokens",
"stream",
"tools",
"tool_choice",
"response_format",
"n",
"stop",
],
userAgent: "codex-cli",
},
claude: {
headerOrder: [
"Host",
"Content-Type",
"x-api-key",
"anthropic-version",
"Accept",
"User-Agent",
"Accept-Encoding",
],
bodyFieldOrder: [
"model",
"max_tokens",
"messages",
"system",
"temperature",
"top_p",
"top_k",
"stream",
"tools",
"tool_choice",
"metadata",
],
userAgent: "claude-code",
},
github: {
headerOrder: [
"Host",
"Authorization",
"X-Request-Id",
"Vscode-Sessionid",
"Vscode-Machineid",
"Editor-Version",
"Editor-Plugin-Version",
"Copilot-Integration-Id",
"Openai-Organization",
"Openai-Intent",
"Content-Type",
"User-Agent",
"Accept",
"Accept-Encoding",
],
bodyFieldOrder: [
"messages",
"model",
"temperature",
"top_p",
"max_tokens",
"n",
"stream",
"intent",
"intent_threshold",
"intent_content",
],
userAgent: "GitHubCopilotChat",
},
antigravity: {
headerOrder: [
"Host",
"Content-Type",
"Authorization",
"User-Agent",
"Accept",
"Accept-Encoding",
],
bodyFieldOrder: ["project", "model", "userAgent", "requestType", "requestId", "request"],
userAgent: "antigravity",
},
};
/**
* Reorder an object's keys according to a specified order.
* Keys not in the order list are appended at the end in their original order.
*/
export function orderFields<T extends Record<string, unknown>>(obj: T, fieldOrder: string[]): T {
if (!fieldOrder?.length || !obj || typeof obj !== "object") return obj;
const result: Record<string, unknown> = {};
const remaining = new Set(Object.keys(obj));
// First, add fields in the specified order
for (const key of fieldOrder) {
if (key in obj) {
result[key] = obj[key];
remaining.delete(key);
}
}
// Then append remaining fields in original order
for (const key of remaining) {
result[key] = obj[key];
}
return result as T;
}
/**
* Reorder HTTP headers according to a fingerprint.
* Returns a new object with headers in the specified order.
*/
export function orderHeaders(
headers: Record<string, string>,
headerOrder: string[]
): Record<string, string> {
if (!headerOrder?.length || !headers) return headers;
const result: Record<string, string> = {};
const remaining = new Map<string, string>();
// Build case-insensitive lookup
const headerMap = new Map<string, [string, string]>();
for (const [key, value] of Object.entries(headers)) {
headerMap.set(key.toLowerCase(), [key, value]);
}
// Add ordered headers first
for (const orderedKey of headerOrder) {
const entry = headerMap.get(orderedKey.toLowerCase());
if (entry) {
result[entry[0]] = entry[1];
headerMap.delete(orderedKey.toLowerCase());
}
}
// Add remaining headers
for (const [, [key, value]] of headerMap) {
result[key] = value;
}
return result;
}
/**
* Apply a CLI fingerprint to headers and body.
* Returns { headers, bodyString } with the correct ordering.
*/
export function applyFingerprint(
provider: string,
headers: Record<string, string>,
body: unknown
): { headers: Record<string, string>; bodyString: string } {
const fingerprint = CLI_FINGERPRINTS[provider?.toLowerCase()];
if (!fingerprint) {
return { headers, bodyString: JSON.stringify(body) };
}
// Apply user agent override
if (fingerprint.userAgent) {
headers["User-Agent"] = fingerprint.userAgent;
}
// Apply extra headers
if (fingerprint.extraHeaders) {
Object.assign(headers, fingerprint.extraHeaders);
}
// Reorder headers
const orderedHeaders = orderHeaders(headers, fingerprint.headerOrder);
// Reorder body fields
const orderedBody =
body && typeof body === "object" && !Array.isArray(body)
? orderFields(body as Record<string, unknown>, fingerprint.bodyFieldOrder)
: body;
return {
headers: orderedHeaders,
bodyString: JSON.stringify(orderedBody),
};
}
/**
* Runtime cache for CLI compat providers set via Settings UI.
* Updated by the settings API when users toggle providers.
*/
let _cliCompatProviders: Set<string> = new Set();
/**
* Update the runtime cache of CLI-compat-enabled providers.
* Called from the settings API when cliCompatProviders is updated.
*/
export function setCliCompatProviders(providers: string[]): void {
_cliCompatProviders = new Set((providers || []).map((p) => p.toLowerCase()));
}
/**
* Get the current list of CLI-compat-enabled providers.
*/
export function getCliCompatProviders(): string[] {
return Array.from(_cliCompatProviders);
}
/**
* Check if CLI compatibility mode is enabled for a provider.
* Reads from: 1) Runtime cache (Settings UI), 2) Environment variables.
*/
export function isCliCompatEnabled(provider: string): boolean {
const key = provider?.toLowerCase().replace(/[^a-z0-9]/g, "_");
// 1. Check runtime cache (set via Settings UI)
if (_cliCompatProviders.has(provider?.toLowerCase())) return true;
// 2. Check environment variable: CLI_COMPAT_<PROVIDER>=1
const envKey = `CLI_COMPAT_${key?.toUpperCase()}`;
if (process.env[envKey] === "1" || process.env[envKey] === "true") return true;
// 3. Global enable: CLI_COMPAT_ALL=1
if (process.env.CLI_COMPAT_ALL === "1" || process.env.CLI_COMPAT_ALL === "true") return true;
return false;
}

View File

@@ -1,4 +1,5 @@
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
type JsonRecord = Record<string, unknown>;
@@ -192,10 +193,20 @@ export class BaseExecutor {
? mergeAbortSignals(signal, timeoutSignal)
: signal || timeoutSignal;
// Apply CLI fingerprint ordering if enabled for this provider
let finalHeaders = headers;
let bodyString = JSON.stringify(transformedBody);
if (isCliCompatEnabled(this.provider)) {
const fingerprinted = applyFingerprint(this.provider, headers, transformedBody);
finalHeaders = fingerprinted.headers;
bodyString = fingerprinted.bodyString;
}
const fetchOptions: RequestInit = {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
headers: finalHeaders,
body: bodyString,
};
if (combinedSignal) fetchOptions.signal = combinedSignal;

View File

@@ -30,9 +30,23 @@ import {
* @param {object} options.body - Request body
* @param {object} options.credentials - Provider credentials { apiKey, accessToken }
* @param {object} options.log - Logger
* @param {string} [options.resolvedProvider] - Pre-resolved provider ID (from route layer custom model resolution)
*/
export async function handleImageGeneration({ body, credentials, log }) {
const { provider, model } = parseImageModel(body.model);
export async function handleImageGeneration({ body, credentials, log, resolvedProvider = null }) {
let provider, model;
if (resolvedProvider) {
// Provider was already resolved by the route layer (custom model from DB)
// Extract model name from the full "provider/model" string
provider = resolvedProvider;
const modelStr = body.model || "";
model = modelStr.startsWith(provider + "/") ? modelStr.slice(provider.length + 1) : modelStr;
} else {
// Standard path: resolve from built-in image registry
const parsed = parseImageModel(body.model);
provider = parsed.provider;
model = parsed.model;
}
if (!provider) {
return {
@@ -43,12 +57,42 @@ export async function handleImageGeneration({ body, credentials, log }) {
}
const providerConfig = getImageProvider(provider);
// For custom models without a built-in provider config, use OpenAI-compatible handler
// with a synthetic config based on the provider's credentials
if (!providerConfig) {
return {
success: false,
status: 400,
error: `Unknown image provider: ${provider}`,
if (!resolvedProvider) {
return {
success: false,
status: 400,
error: `Unknown image provider: ${provider}`,
};
}
// Custom model: use OpenAI-compatible format with provider's base URL
// The credentials were already resolved by the route layer
if (log) {
log.info("IMAGE", `Custom model ${provider}/${model} — using OpenAI-compatible handler`);
}
const syntheticConfig = {
id: provider,
baseUrl:
credentials?.baseUrl ||
`https://generativelanguage.googleapis.com/v1beta/openai/images/generations`,
authType: "apikey",
authHeader: "bearer",
format: "openai",
};
return handleOpenAIImageGeneration({
model,
provider,
providerConfig: syntheticConfig,
body,
credentials,
log,
});
}
// Route to format-specific handler

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.0.6",
"version": "2.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.0.6",
"version": "2.0.9",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.0.7",
"version": "2.0.10",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {

View File

@@ -0,0 +1,400 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Card, Button, Select, Badge } from "@/shared/components";
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
interface ModelInfo {
id: string;
object: string;
owned_by: string;
}
interface ProviderOption {
value: string;
label: string;
}
const ENDPOINT_OPTIONS = [
{ value: "chat", label: "Chat Completions" },
{ value: "responses", label: "Responses" },
{ value: "images", label: "Image Generation" },
{ value: "embeddings", label: "Embeddings" },
];
const DEFAULT_BODIES: Record<string, object> = {
chat: {
model: "",
messages: [{ role: "user", content: "Hello! Say hi in one sentence." }],
max_tokens: 100,
stream: false,
},
responses: {
model: "",
input: "Hello! Say hi in one sentence.",
stream: false,
},
images: {
model: "",
prompt: "A beautiful sunset over mountains",
n: 1,
size: "1024x1024",
},
embeddings: {
model: "",
input: "Hello world",
encoding_format: "float",
},
};
const ENDPOINT_PATHS: Record<string, string> = {
chat: "/v1/chat/completions",
responses: "/v1/responses",
images: "/v1/images/generations",
embeddings: "/v1/embeddings",
};
export default function PlaygroundPage() {
const [models, setModels] = useState<ModelInfo[]>([]);
const [providers, setProviders] = useState<ProviderOption[]>([]);
const [selectedProvider, setSelectedProvider] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [selectedEndpoint, setSelectedEndpoint] = useState("chat");
const [requestBody, setRequestBody] = useState("");
const [responseBody, setResponseBody] = useState("");
const [loading, setLoading] = useState(false);
const [responseStatus, setResponseStatus] = useState<number | null>(null);
const [responseDuration, setResponseDuration] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Fetch models
useEffect(() => {
fetch("/v1/models")
.then((res) => res.json())
.then((data) => {
const modelList = (data?.data || []) as ModelInfo[];
setModels(modelList);
// Extract unique providers from model ids (provider/model format)
const providerSet = new Set<string>();
modelList.forEach((m) => {
const parts = m.id.split("/");
if (parts.length >= 2) providerSet.add(parts[0]);
});
const providerOpts = Array.from(providerSet)
.sort()
.map((p) => ({ value: p, label: p }));
setProviders(providerOpts);
if (providerOpts.length > 0) setSelectedProvider(providerOpts[0].value);
})
.catch(() => {});
}, []);
// Filter models by selected provider
const filteredModels = models
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
.map((m) => ({ value: m.id, label: m.id }));
// Helper to generate default body for a given endpoint and model
const generateDefaultBody = (endpoint: string, model: string) => {
const template = { ...DEFAULT_BODIES[endpoint] };
if ("model" in template) {
(template as any).model = model;
}
return JSON.stringify(template, null, 2);
};
// When provider changes, auto-select first model and reset body
const handleProviderChange = (newProvider: string) => {
setSelectedProvider(newProvider);
const providerModels = models
.filter((m) => !newProvider || m.id.startsWith(newProvider + "/"))
.map((m) => m.id);
const firstModel = providerModels[0] || "";
setSelectedModel(firstModel);
setRequestBody(generateDefaultBody(selectedEndpoint, firstModel));
setResponseBody("");
setResponseStatus(null);
setResponseDuration(null);
};
// When model changes, update body
const handleModelChange = (newModel: string) => {
setSelectedModel(newModel);
setRequestBody(generateDefaultBody(selectedEndpoint, newModel));
setResponseBody("");
setResponseStatus(null);
setResponseDuration(null);
};
// When endpoint changes, update body
const handleEndpointChange = (newEndpoint: string) => {
setSelectedEndpoint(newEndpoint);
setRequestBody(generateDefaultBody(newEndpoint, selectedModel));
setResponseBody("");
setResponseStatus(null);
setResponseDuration(null);
};
const handleSend = useCallback(async () => {
if (!requestBody.trim()) return;
setLoading(true);
setResponseBody("");
setResponseStatus(null);
setResponseDuration(null);
const controller = new AbortController();
abortRef.current = controller;
const startTime = Date.now();
try {
const parsed = JSON.parse(requestBody);
const path = ENDPOINT_PATHS[selectedEndpoint];
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(parsed),
signal: controller.signal,
});
setResponseStatus(res.status);
setResponseDuration(Date.now() - startTime);
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("text/event-stream")) {
// Handle streaming
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let accumulated = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
accumulated += decoder.decode(value, { stream: true });
setResponseBody(accumulated);
}
}
} else {
const data = await res.json();
setResponseBody(JSON.stringify(data, null, 2));
}
} catch (err: any) {
if (err.name === "AbortError") {
setResponseBody(JSON.stringify({ cancelled: true }, null, 2));
} else {
setResponseBody(JSON.stringify({ error: err.message }, null, 2));
}
setResponseDuration(Date.now() - startTime);
}
setLoading(false);
}, [requestBody, selectedEndpoint]);
const handleCancel = () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch {
/* silent */
}
};
return (
<div className="space-y-5">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
science
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Model Playground</p>
<p>
Test any model directly from the dashboard. Pick a provider, model, and endpoint type,
then send a request to see the raw response.
</p>
</div>
</div>
{/* Controls */}
<Card>
<div className="p-4 flex flex-col sm:flex-row items-end gap-4">
{/* Provider */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Provider
</label>
<Select
value={selectedProvider}
onChange={(e: any) => handleProviderChange(e.target.value)}
options={providers}
className="w-full"
/>
</div>
{/* Model */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Model
</label>
<Select
value={selectedModel}
onChange={(e: any) => handleModelChange(e.target.value)}
options={filteredModels}
className="w-full"
/>
</div>
{/* Endpoint */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Endpoint
</label>
<Select
value={selectedEndpoint}
onChange={(e: any) => handleEndpointChange(e.target.value)}
options={ENDPOINT_OPTIONS}
className="w-full"
/>
</div>
{/* Send Button */}
<div className="shrink-0">
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
Cancel
</Button>
) : (
<Button
icon="send"
onClick={handleSend}
disabled={!requestBody.trim() || !selectedModel}
>
Send
</Button>
)}
</div>
</div>
</Card>
{/* Split Editor View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
upload
</span>
<h3 className="text-sm font-semibold text-text-main">Request</h3>
<Badge variant="info" size="sm">
POST {ENDPOINT_PATHS[selectedEndpoint]}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={() => {
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
if ("model" in template) (template as any).model = selectedModel;
setRequestBody(JSON.stringify(template, null, 2));
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Reset to default"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={requestBody}
onChange={(value: string | undefined) => setRequestBody(value || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
</div>
</Card>
{/* Response Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">Response</h3>
{responseStatus !== null && (
<Badge
variant={responseStatus >= 200 && responseStatus < 300 ? "success" : "error"}
size="sm"
>
{responseStatus}
</Badge>
)}
{responseDuration !== null && (
<span className="text-xs text-text-muted">{responseDuration}ms</span>
)}
{loading && (
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
progress_activity
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(responseBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={responseBody}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -248,6 +248,76 @@ export default function SecurityTab() {
</div>
</Card>
{/* CLI Fingerprint Matching */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
fingerprint
</span>
</div>
<h3 className="text-lg font-semibold">{t("cliFingerprint")}</h3>
</div>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">{t("cliFingerprintDesc")}</p>
<div className="flex flex-wrap gap-2">
{(["codex", "claude", "github", "antigravity"] as const).map((providerId) => {
const providerMeta = Object.values(AI_PROVIDERS).find(
(p: any) => p.id === providerId
) as any;
const isEnabled = (settings.cliCompatProviders || []).includes(providerId);
const displayName = providerMeta?.name || providerId;
const icon = providerMeta?.icon || "terminal";
const color = providerMeta?.color || "#888";
return (
<button
key={providerId}
onClick={() => {
const current: string[] = settings.cliCompatProviders || [];
const updated = current.includes(providerId)
? current.filter((p) => p !== providerId)
: [...current, providerId];
updateSetting("cliCompatProviders", updated);
}}
disabled={loading}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all border ${
isEnabled
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
: "bg-black/[0.02] dark:bg-white/[0.02] border-transparent text-text-muted hover:bg-black/[0.05] dark:hover:bg-white/[0.05]"
}`}
title={
isEnabled
? t("disableFingerprintTitle", { provider: displayName })
: t("enableFingerprintTitle", { provider: displayName })
}
>
<span
className="material-symbols-outlined text-[14px]"
style={{ color: isEnabled ? undefined : color }}
>
{isEnabled ? "fingerprint" : icon}
</span>
{displayName}
{isEnabled && (
<span className="material-symbols-outlined text-[12px] text-emerald-500">
check
</span>
)}
</button>
);
})}
</div>
{(settings.cliCompatProviders || []).length > 0 && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">verified</span>
{t("cliFingerprintEnabled", {
count: (settings.cliCompatProviders || []).length,
})}
</p>
)}
</div>
</Card>
<SessionInfoCard />
<IPFilterSection />
</div>

View File

@@ -0,0 +1,35 @@
/**
* API Route: /api/acp/agents
*
* Returns the list of detected CLI agents and their availability status.
* Used by the dashboard to show ACP transport options.
*/
import { NextResponse } from "next/server";
import { detectInstalledAgents } from "@/lib/acp";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const agents = detectInstalledAgents();
return NextResponse.json({
agents: agents.map((a) => ({
id: a.id,
name: a.name,
binary: a.binary,
version: a.version,
installed: a.installed,
providerAlias: a.providerAlias,
protocol: a.protocol,
})),
available: agents.filter((a) => a.installed).length,
total: agents.length,
});
} catch (error: any) {
return NextResponse.json(
{ error: error.message || "Failed to detect agents" },
{ status: 500 }
);
}
}

View File

@@ -5,12 +5,18 @@ import bcrypt from "bcryptjs";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { setCliCompatProviders } from "../../../../open-sse/config/cliFingerprints";
export async function GET() {
try {
const settings = await getSettings();
const { password, ...safeSettings } = settings;
// Sync CLI fingerprint providers to runtime cache on load
if (settings.cliCompatProviders) {
setCliCompatProviders(settings.cliCompatProviders as string[]);
}
const enableRequestLogs = process.env.ENABLE_REQUEST_LOGS === "true";
const runtimePorts = getRuntimePorts();
@@ -74,6 +80,11 @@ export async function PATCH(request) {
clearHealthCheckLogCache();
}
// Sync CLI fingerprint providers to runtime cache
if ("cliCompatProviders" in body) {
setCliCompatProviders(body.cliCompatProviders || []);
}
const { password, ...safeSettings } = settings;
return NextResponse.json(safeSettings);
} catch (error) {

View File

@@ -108,6 +108,7 @@ export async function POST(request) {
// Parse model to get provider
let { provider } = parseImageModel(body.model);
let isCustomModel = false;
// If not in built-in registry, check custom models tagged for images
if (!provider) {
@@ -121,6 +122,7 @@ export async function POST(request) {
const fullId = `${providerId}/${model.id}`;
if (fullId === body.model) {
provider = providerId;
isCustomModel = true;
break;
}
}
@@ -149,9 +151,23 @@ export async function POST(request) {
`No credentials for image provider: ${provider}`
);
}
} else if (isCustomModel) {
// Custom models need credentials from the provider connection
credentials = await getProviderCredentials(provider);
if (!credentials) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No credentials for custom image provider: ${provider}`
);
}
}
const result = await handleImageGeneration({ body, credentials, log });
const result = await handleImageGeneration({
body,
credentials,
log,
...(isCustomModel && { resolvedProvider: provider }),
});
if (result.success) {
return new Response(JSON.stringify((result as any).data), {

View File

@@ -100,7 +100,8 @@
"themeViolet": "بنفسجي ساحر",
"themeOrange": "أورانج (Orange)",
"themeCyan": "كيان",
"endpoints": "نقاط النهاية"
"endpoints": "نقاط النهاية",
"playground": "ملعب النماذج"
},
"themesPage": {
"title": "المواضيع",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Виолетово",
"themeOrange": "Оранжево",
"themeCyan": "Циан",
"endpoints": "Крайни точки"
"endpoints": "Крайни точки",
"playground": "Площадка"
},
"themesPage": {
"title": "Теми",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Turkis",
"endpoints": "Endpoints"
"endpoints": "Endpoints",
"playground": "Legeplads"
},
"themesPage": {
"title": "Temaer",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violett",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Endpunkte"
"endpoints": "Endpunkte",
"playground": "Spielwiese"
},
"themesPage": {
"title": "Themen",

View File

@@ -72,6 +72,7 @@
"media": "Media",
"settings": "Settings",
"translator": "Translator",
"playground": "Playground",
"docs": "Docs",
"issues": "Issues",
"endpoints": "Endpoints",
@@ -1492,6 +1493,11 @@
"providersBlocked": "{count} provider(s) blocked from /models",
"blockProviderTitle": "Block {provider}",
"unblockProviderTitle": "Unblock {provider}",
"cliFingerprint": "CLI Fingerprint Matching",
"cliFingerprintDesc": "Match native CLI binary signatures when proxying requests. Reorders headers and body fields to look identical to the official CLI tools. Your proxy IP is preserved.",
"cliFingerprintEnabled": "{count} provider(s) with CLI fingerprint active",
"enableFingerprintTitle": "Enable fingerprint for {provider}",
"disableFingerprintTitle": "Disable fingerprint for {provider}",
"routingStrategy": "Routing Strategy",
"fillFirst": "Fill First",
"fillFirstDesc": "Use accounts in priority order",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violeta",
"themeOrange": "Naranja",
"themeCyan": "cian",
"endpoints": "Endpoints"
"endpoints": "Endpoints",
"playground": "Playground"
},
"themesPage": {
"title": "Temas",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violetti",
"themeOrange": "Oranssi",
"themeCyan": "Syaani",
"endpoints": "Päätepisteet"
"endpoints": "Päätepisteet",
"playground": "Leikkipaikka"
},
"themesPage": {
"title": "Teemat",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violette",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Points d'accès"
"endpoints": "Points d'accès",
"playground": "Playground"
},
"themesPage": {
"title": "Thèmes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "נקודות קצה"
"endpoints": "נקודות קצה",
"playground": "Playground"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "narancssárga",
"themeCyan": "Cián",
"endpoints": "Végpontok"
"endpoints": "Végpontok",
"playground": "Játszótér"
},
"themesPage": {
"title": "Témák",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Endpoint"
"endpoints": "Endpoint",
"playground": "Playground"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Endpoint"
"endpoints": "Endpoint",
"playground": "Playground"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Endpoint"
"endpoints": "Endpoint",
"playground": "Playground"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "エンドポイント"
"endpoints": "エンドポイント",
"playground": "プレイグラウンド"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "엔드포인트"
"endpoints": "엔드포인트",
"playground": "플레이그라운드"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Titik Akhir"
"endpoints": "Titik Akhir",
"playground": "Playground"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Eindpunten"
"endpoints": "Eindpunten",
"playground": "Speeltuin"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Endepunkter"
"endpoints": "Endepunkter",
"playground": "Lekeplass"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Mga Endpoint"
"endpoints": "Mga Endpoint",
"playground": "Playground"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Punkty końcowe"
"endpoints": "Punkty końcowe",
"playground": "Plac zabaw"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Endpoints"
"endpoints": "Endpoints",
"playground": "Playground"
},
"themesPage": {
"title": "Themes",
@@ -1480,6 +1481,11 @@
"providersBlocked": "{count} provedor(es) bloqueado(s) do /models",
"blockProviderTitle": "Bloquear {provider}",
"unblockProviderTitle": "Desbloquear {provider}",
"cliFingerprint": "Assinatura Digital CLI",
"cliFingerprintDesc": "Reproduz a assinatura digital dos CLIs nativos ao fazer proxy. Reorganiza headers e campos do body para parecer idêntico às ferramentas CLI oficiais. O IP do proxy é preservado.",
"cliFingerprintEnabled": "{count} provider(s) com fingerprint CLI ativo",
"enableFingerprintTitle": "Ativar fingerprint para {provider}",
"disableFingerprintTitle": "Desativar fingerprint para {provider}",
"routingStrategy": "Estratégia de Roteamento",
"fillFirst": "Preencher Primeiro",
"fillFirstDesc": "Usar contas em ordem de prioridade",

View File

@@ -100,7 +100,8 @@
"themeGreen": "Verde",
"themeViolet": "Violeta",
"themeOrange": "Laranja",
"themeCyan": "Ciano"
"themeCyan": "Ciano",
"playground": "Playground"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Puncte finale"
"endpoints": "Puncte finale",
"playground": "Playground"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Фиолетовый",
"themeOrange": "Оранжевый",
"themeCyan": "Голубой",
"endpoints": "Конечные точки"
"endpoints": "Конечные точки",
"playground": "Площадка"
},
"themesPage": {
"title": "Темы",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Koncové body"
"endpoints": "Koncové body",
"playground": "Ihrisko"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Ändpunkter"
"endpoints": "Ändpunkter",
"playground": "Lekplats"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "จุดปลายทาง"
"endpoints": "จุดปลายทาง",
"playground": "Playground"
},
"themesPage": {
"title": "ธีมส์",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Кінцеві точки"
"endpoints": "Кінцеві точки",
"playground": "Playground"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "Điểm cuối"
"endpoints": "Điểm cuối",
"playground": "Playground"
},
"themesPage": {
"title": "Themes",

View File

@@ -100,7 +100,8 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"endpoints": "端点"
"endpoints": "端点",
"playground": "模型试验场"
},
"themesPage": {
"title": "Themes",

11
src/lib/acp/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* ACP Module — Public API
*
* Re-exports the registry and manager for convenient imports.
*/
export { detectInstalledAgents, getAgentById, getAvailableAgents } from "./registry";
export type { CliAgentInfo } from "./registry";
export { AcpManager, acpManager } from "./manager";
export type { AcpSession } from "./manager";

197
src/lib/acp/manager.ts Normal file
View File

@@ -0,0 +1,197 @@
/**
* ACP (Agent Client Protocol) — Process Spawner & Manager
*
* Spawns CLI agents as child processes and manages their lifecycle.
* Communication happens via stdin/stdout (JSON-RPC style) or piped HTTP.
*
* This module provides a "CLI-as-backend" transport: instead of intercepting
* HTTP API calls, OmniRoute spawns the CLI directly and feeds prompts through
* its native interface.
*/
import { spawn, ChildProcess } from "child_process";
import { EventEmitter } from "events";
export interface AcpSession {
/** Unique session ID */
id: string;
/** Agent ID (e.g., "codex", "claude") */
agentId: string;
/** Child process handle */
process: ChildProcess;
/** Whether the process is alive */
alive: boolean;
/** Accumulated stdout buffer */
stdoutBuffer: string;
/** Accumulated stderr buffer */
stderrBuffer: string;
/** Created timestamp */
createdAt: Date;
}
/**
* ACP Session Manager
*
* Manages the lifecycle of CLI agent processes.
* Each session represents one running CLI agent instance.
*/
export class AcpManager extends EventEmitter {
private sessions: Map<string, AcpSession> = new Map();
/**
* Spawn a new CLI agent process.
*/
spawn(
agentId: string,
binary: string,
args: string[] = [],
env: Record<string, string> = {}
): AcpSession {
const sessionId = `acp-${agentId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const child = spawn(binary, args, {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, ...env },
shell: false,
});
const session: AcpSession = {
id: sessionId,
agentId,
process: child,
alive: true,
stdoutBuffer: "",
stderrBuffer: "",
createdAt: new Date(),
};
child.stdout?.on("data", (chunk: Buffer) => {
session.stdoutBuffer += chunk.toString();
this.emit("stdout", { sessionId, data: chunk.toString() });
});
child.stderr?.on("data", (chunk: Buffer) => {
session.stderrBuffer += chunk.toString();
this.emit("stderr", { sessionId, data: chunk.toString() });
});
child.on("exit", (code, signal) => {
session.alive = false;
this.emit("exit", { sessionId, code, signal });
});
child.on("error", (err) => {
session.alive = false;
this.emit("error", { sessionId, error: err });
});
this.sessions.set(sessionId, session);
return session;
}
/**
* Send input to a running session's stdin.
*/
sendInput(sessionId: string, input: string): boolean {
const session = this.sessions.get(sessionId);
if (!session?.alive || !session.process.stdin?.writable) return false;
session.process.stdin.write(input);
return true;
}
/**
* Send a prompt to a CLI agent and collect the response.
* This is a higher-level method that handles the send/receive cycle.
*/
async sendPrompt(sessionId: string, prompt: string, timeoutMs: number = 120000): Promise<string> {
const session = this.sessions.get(sessionId);
if (!session?.alive) throw new Error(`Session ${sessionId} is not alive`);
// Clear buffer before sending
session.stdoutBuffer = "";
// Send prompt
this.sendInput(sessionId, prompt + "\n");
// Wait for response (collect until process goes idle or timeout)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`ACP timeout after ${timeoutMs}ms`));
}, timeoutMs);
let idleTimer: ReturnType<typeof setTimeout>;
const onData = ({ sessionId: sid }: { sessionId: string }) => {
if (sid !== sessionId) return;
// Reset idle timer on new data
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
clearTimeout(timer);
this.removeListener("stdout", onData);
this.removeListener("exit", onExit);
resolve(session.stdoutBuffer);
}, 2000); // 2s idle = response complete
};
const onExit = ({ sessionId: sid }: { sessionId: string }) => {
if (sid !== sessionId) return;
clearTimeout(timer);
clearTimeout(idleTimer);
this.removeListener("stdout", onData);
this.removeListener("exit", onExit);
resolve(session.stdoutBuffer);
};
this.on("stdout", onData);
this.on("exit", onExit);
});
}
/**
* Kill a session and clean up.
*/
kill(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
if (session.alive) {
session.process.kill("SIGTERM");
// Force kill after 5s
setTimeout(() => {
if (session.alive) {
session.process.kill("SIGKILL");
}
}, 5000);
}
this.sessions.delete(sessionId);
return true;
}
/**
* Get all active sessions.
*/
getActiveSessions(): AcpSession[] {
return Array.from(this.sessions.values()).filter((s) => s.alive);
}
/**
* Get a specific session.
*/
getSession(sessionId: string): AcpSession | undefined {
return this.sessions.get(sessionId);
}
/**
* Kill all sessions.
*/
killAll(): void {
for (const [id] of this.sessions) {
this.kill(id);
}
}
}
// Singleton manager instance
export const acpManager = new AcpManager();

126
src/lib/acp/registry.ts Normal file
View File

@@ -0,0 +1,126 @@
/**
* ACP (Agent Client Protocol) — CLI Agent Registry
*
* Discovers installed CLI tools on the system by checking standard paths
* and running version commands. Used to offer ACP transport as an alternative
* to the HTTP proxy method.
*
* Reference: https://github.com/iOfficeAI/AionUi (auto-detects CLI agents)
*/
import { execSync } from "child_process";
export interface CliAgentInfo {
/** Agent identifier (e.g., "codex", "claude", "goose") */
id: string;
/** Display name */
name: string;
/** Binary name to spawn */
binary: string;
/** Version detection command */
versionCommand: string;
/** Detected version (null if not installed) */
version: string | null;
/** Whether the agent is installed and available */
installed: boolean;
/** Provider ID that this agent maps to in OmniRoute */
providerAlias: string;
/** Arguments to pass when spawning for ACP */
spawnArgs: string[];
/** Protocol used for communication */
protocol: "stdio" | "http";
}
/**
* Registry of known CLI agents that support ACP or similar protocols.
*/
const AGENT_DEFINITIONS: Omit<CliAgentInfo, "version" | "installed">[] = [
{
id: "codex",
name: "OpenAI Codex CLI",
binary: "codex",
versionCommand: "codex --version",
providerAlias: "codex",
spawnArgs: ["--quiet"],
protocol: "stdio",
},
{
id: "claude",
name: "Claude Code CLI",
binary: "claude",
versionCommand: "claude --version",
providerAlias: "claude",
spawnArgs: ["--print", "--output-format", "json"],
protocol: "stdio",
},
{
id: "goose",
name: "Goose CLI",
binary: "goose",
versionCommand: "goose --version",
providerAlias: "goose",
spawnArgs: [],
protocol: "stdio",
},
{
id: "gemini-cli",
name: "Gemini CLI",
binary: "gemini",
versionCommand: "gemini --version",
providerAlias: "gemini-cli",
spawnArgs: [],
protocol: "stdio",
},
{
id: "openclaw",
name: "OpenClaw",
binary: "openclaw",
versionCommand: "openclaw --version",
providerAlias: "openclaw",
spawnArgs: [],
protocol: "stdio",
},
];
/**
* Detect installed CLI agents on the system.
* Runs version commands to verify availability.
*/
export function detectInstalledAgents(): CliAgentInfo[] {
return AGENT_DEFINITIONS.map((def) => {
let version: string | null = null;
let installed = false;
try {
const output = execSync(def.versionCommand, {
timeout: 5000,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
// Extract version number from output
const versionMatch = output.match(/(\d+\.\d+\.\d+(?:-\w+)?)/);
version = versionMatch ? versionMatch[1] : output.split("\n")[0];
installed = true;
} catch {
// Not installed or not runnable
}
return { ...def, version, installed };
});
}
/**
* Get a specific agent by ID.
*/
export function getAgentById(id: string): CliAgentInfo | undefined {
const agents = detectInstalledAgents();
return agents.find((a) => a.id === id);
}
/**
* Get agents that are installed and available for ACP.
*/
export function getAvailableAgents(): CliAgentInfo[] {
return detectInstalledAgents().filter((a) => a.installed);
}

View File

@@ -27,7 +27,10 @@ const navItemDefs = [
{ href: "/dashboard/media", i18nKey: "media", icon: "auto_awesome" },
];
const debugItemDefs = [{ href: "/dashboard/translator", i18nKey: "translator", icon: "translate" }];
const debugItemDefs = [
{ href: "/dashboard/translator", i18nKey: "translator", icon: "translate" },
{ href: "/dashboard/playground", i18nKey: "playground", icon: "science" },
];
const systemItemDefs = [{ href: "/dashboard/settings", i18nKey: "settings", icon: "settings" }];

View File

@@ -34,4 +34,6 @@ export const updateSettingsSchema = z.object({
mcpEnabled: z.boolean().optional(),
mcpTransport: z.enum(["stdio", "sse", "streamable-http"]).optional(),
a2aEnabled: z.boolean().optional(),
// CLI Fingerprint compatibility (per-provider)
cliCompatProviders: z.array(z.string().max(100)).optional(),
});