mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-27 02:12:19 +03:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbd60c853e | ||
|
|
2d8091340f | ||
|
|
2025c16c82 | ||
|
|
811fb7f9b2 | ||
|
|
a2bd32e76c | ||
|
|
89c07f4e8a | ||
|
|
ac89069671 | ||
|
|
7cb420d8e6 | ||
|
|
d3919d441f | ||
|
|
4b5824babc |
@@ -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.
|
||||
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -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
|
||||
|
||||
264
open-sse/config/cliFingerprints.ts
Normal file
264
open-sse/config/cliFingerprints.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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": [
|
||||
|
||||
@@ -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": {
|
||||
|
||||
400
src/app/(dashboard)/dashboard/playground/page.tsx
Normal file
400
src/app/(dashboard)/dashboard/playground/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
35
src/app/api/acp/agents/route.ts
Normal file
35
src/app/api/acp/agents/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "بنفسجي ساحر",
|
||||
"themeOrange": "أورانج (Orange)",
|
||||
"themeCyan": "كيان",
|
||||
"endpoints": "نقاط النهاية"
|
||||
"endpoints": "نقاط النهاية",
|
||||
"playground": "ملعب النماذج"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "المواضيع",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Виолетово",
|
||||
"themeOrange": "Оранжево",
|
||||
"themeCyan": "Циан",
|
||||
"endpoints": "Крайни точки"
|
||||
"endpoints": "Крайни точки",
|
||||
"playground": "Площадка"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Теми",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Turkis",
|
||||
"endpoints": "Endpoints"
|
||||
"endpoints": "Endpoints",
|
||||
"playground": "Legeplads"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Temaer",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violett",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Endpunkte"
|
||||
"endpoints": "Endpunkte",
|
||||
"playground": "Spielwiese"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themen",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violeta",
|
||||
"themeOrange": "Naranja",
|
||||
"themeCyan": "cian",
|
||||
"endpoints": "Endpoints"
|
||||
"endpoints": "Endpoints",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Temas",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violetti",
|
||||
"themeOrange": "Oranssi",
|
||||
"themeCyan": "Syaani",
|
||||
"endpoints": "Päätepisteet"
|
||||
"endpoints": "Päätepisteet",
|
||||
"playground": "Leikkipaikka"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Teemat",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "נקודות קצה"
|
||||
"endpoints": "נקודות קצה",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Endpoint"
|
||||
"endpoints": "Endpoint",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Endpoint"
|
||||
"endpoints": "Endpoint",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Endpoint"
|
||||
"endpoints": "Endpoint",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "エンドポイント"
|
||||
"endpoints": "エンドポイント",
|
||||
"playground": "プレイグラウンド"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "엔드포인트"
|
||||
"endpoints": "엔드포인트",
|
||||
"playground": "플레이그라운드"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Titik Akhir"
|
||||
"endpoints": "Titik Akhir",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Eindpunten"
|
||||
"endpoints": "Eindpunten",
|
||||
"playground": "Speeltuin"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Endepunkter"
|
||||
"endpoints": "Endepunkter",
|
||||
"playground": "Lekeplass"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Mga Endpoint"
|
||||
"endpoints": "Mga Endpoint",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Punkty końcowe"
|
||||
"endpoints": "Punkty końcowe",
|
||||
"playground": "Plac zabaw"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeGreen": "Verde",
|
||||
"themeViolet": "Violeta",
|
||||
"themeOrange": "Laranja",
|
||||
"themeCyan": "Ciano"
|
||||
"themeCyan": "Ciano",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Puncte finale"
|
||||
"endpoints": "Puncte finale",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Фиолетовый",
|
||||
"themeOrange": "Оранжевый",
|
||||
"themeCyan": "Голубой",
|
||||
"endpoints": "Конечные точки"
|
||||
"endpoints": "Конечные точки",
|
||||
"playground": "Площадка"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Темы",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Koncové body"
|
||||
"endpoints": "Koncové body",
|
||||
"playground": "Ihrisko"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Ändpunkter"
|
||||
"endpoints": "Ändpunkter",
|
||||
"playground": "Lekplats"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "จุดปลายทาง"
|
||||
"endpoints": "จุดปลายทาง",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "ธีมส์",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "Кінцеві точки"
|
||||
"endpoints": "Кінцеві точки",
|
||||
"playground": "Playground"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"endpoints": "端点"
|
||||
"endpoints": "端点",
|
||||
"playground": "模型试验场"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
11
src/lib/acp/index.ts
Normal file
11
src/lib/acp/index.ts
Normal 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
197
src/lib/acp/manager.ts
Normal 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
126
src/lib/acp/registry.ts
Normal 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);
|
||||
}
|
||||
@@ -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" }];
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user