merge origin/master into gitea/new_feature
Bring local branch up to v0.5.35 while keeping xAI image/edit, SuperGrok quota tracking, per-provider timeouts, and pinned model-test actions.
This commit is contained in:
1215
src/shared/components/ApiExplorerModal.js
Normal file
1215
src/shared/components/ApiExplorerModal.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,8 @@ const getLocaleInfo = (locale) => {
|
||||
"hu": { name: "Magyar", flag: "🇭🇺" },
|
||||
"fi": { name: "Suomi", flag: "🇫🇮" },
|
||||
"da": { name: "Dansk", flag: "🇩🇰" },
|
||||
"no": { name: "Norsk", flag: "🇳🇴" }
|
||||
"no": { name: "Norsk", flag: "🇳🇴" },
|
||||
"fa": { name: "فارسی", flag: "🇮🇷" }
|
||||
};
|
||||
return locales[locale] || { name: locale, flag: "🌐" };
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function Modal({
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
full: "max-w-4xl",
|
||||
full: "max-w-6xl",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,7 +99,10 @@ export default function Modal({
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 max-h-[calc(85vh-100px)] overflow-y-auto custom-scrollbar">{children}</div>
|
||||
<div className={cn(
|
||||
"p-6 overflow-y-auto custom-scrollbar",
|
||||
size === "full" ? "max-h-[calc(92vh-100px)]" : "max-h-[calc(85vh-100px)]"
|
||||
)}>{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Card from "./Card";
|
||||
import Select from "./Select";
|
||||
import Badge from "./Badge";
|
||||
|
||||
const NONE_PROXY_POOL_VALUE = "__none__";
|
||||
const STRATEGIES = [
|
||||
{ value: "none", label: "None (single pool)" },
|
||||
{ value: "round-robin", label: "Round-robin" },
|
||||
{ value: "random", label: "Random" },
|
||||
];
|
||||
|
||||
export default function NoAuthProxyCard({ providerId }) {
|
||||
const [proxyPools, setProxyPools] = useState([]);
|
||||
const [proxyPoolId, setProxyPoolId] = useState(NONE_PROXY_POOL_VALUE);
|
||||
const [rotateStrategy, setRotateStrategy] = useState("none");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedFlash, setSavedFlash] = useState(false);
|
||||
|
||||
@@ -24,20 +30,22 @@ export default function NoAuthProxyCard({ providerId }) {
|
||||
setProxyPools(poolData.proxyPools || []);
|
||||
const override = (settingsData.providerStrategies || {})[providerId] || {};
|
||||
setProxyPoolId(override.proxyPoolId || NONE_PROXY_POOL_VALUE);
|
||||
setRotateStrategy(override.rotateStrategy || "none");
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [providerId]);
|
||||
|
||||
const handleChange = async (newValue) => {
|
||||
setProxyPoolId(newValue);
|
||||
const save = useCallback(async (poolId, strategy) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings", { cache: "no-store" });
|
||||
const data = res.ok ? await res.json() : {};
|
||||
const current = data.providerStrategies || {};
|
||||
const override = { ...(current[providerId] || {}) };
|
||||
if (newValue === NONE_PROXY_POOL_VALUE) delete override.proxyPoolId;
|
||||
else override.proxyPoolId = newValue;
|
||||
if (poolId === NONE_PROXY_POOL_VALUE) delete override.proxyPoolId;
|
||||
else override.proxyPoolId = poolId;
|
||||
if (strategy === "none") delete override.rotateStrategy;
|
||||
else override.rotateStrategy = strategy;
|
||||
const updated = { ...current };
|
||||
if (Object.keys(override).length === 0) delete updated[providerId];
|
||||
else updated[providerId] = override;
|
||||
@@ -49,12 +57,25 @@ export default function NoAuthProxyCard({ providerId }) {
|
||||
setSavedFlash(true);
|
||||
setTimeout(() => setSavedFlash(false), 1500);
|
||||
} catch (e) {
|
||||
console.log("Save proxyPoolId error:", e);
|
||||
console.log("Save proxy config error:", e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [providerId]);
|
||||
|
||||
const handlePoolChange = (newPoolId) => {
|
||||
setProxyPoolId(newPoolId);
|
||||
save(newPoolId, rotateStrategy);
|
||||
};
|
||||
|
||||
const handleStrategyChange = (newStrategy) => {
|
||||
setRotateStrategy(newStrategy);
|
||||
save(proxyPoolId, newStrategy);
|
||||
};
|
||||
|
||||
const canRotate = proxyPools.length >= 2;
|
||||
const isRotation = rotateStrategy !== "none";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -67,16 +88,43 @@ export default function NoAuthProxyCard({ providerId }) {
|
||||
</div>
|
||||
{savedFlash && <Badge variant="success" size="sm">Saved</Badge>}
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Proxy Pool"
|
||||
value={proxyPoolId}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
disabled={saving}
|
||||
onChange={(e) => handlePoolChange(e.target.value)}
|
||||
disabled={saving || isRotation}
|
||||
options={[
|
||||
{ value: NONE_PROXY_POOL_VALUE, label: "None (direct)" },
|
||||
...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })),
|
||||
]}
|
||||
hint={isRotation ? "Pool selector is ignored when rotation is active — all active pools are used." : undefined}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<label className="text-sm font-medium text-text-main">Rotation Strategy</label>
|
||||
<select
|
||||
value={rotateStrategy}
|
||||
onChange={(e) => handleStrategyChange(e.target.value)}
|
||||
disabled={saving}
|
||||
className="py-2 px-3 text-sm text-text-main bg-white dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-md focus:ring-1 focus:ring-primary/30 focus:border-primary/50 focus:outline-none transition-all disabled:opacity-50"
|
||||
>
|
||||
{STRATEGIES.map((s) => (
|
||||
<option key={s.value} value={s.value} disabled={s.value !== "none" && !canRotate}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-text-muted">
|
||||
{!canRotate
|
||||
? `Need at least 2 active proxy pools for rotation.`
|
||||
: isRotation
|
||||
? rotateStrategy === "round-robin"
|
||||
? `Rotating through all ${proxyPools.length} active pools in order. State is in-memory (resets on restart).`
|
||||
: `Picking a random pool from ${proxyPools.length} active pools each request.`
|
||||
: `Uses the selected pool above. Set to Round-robin or Random to rotate across all active pools.`}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,8 +156,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Device code flow providers
|
||||
const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode", "codebuddy-cn", "qoder"];
|
||||
// Device code flow providers (must match oauth providers with flowType: "device_code")
|
||||
const deviceCodeProviders = [
|
||||
"github",
|
||||
"qwen",
|
||||
"kiro",
|
||||
"kimi-coding",
|
||||
"kilocode",
|
||||
"codebuddy-cn",
|
||||
"qoder",
|
||||
"grok-cli",
|
||||
];
|
||||
if (deviceCodeProviders.includes(provider)) {
|
||||
setIsDeviceCode(true);
|
||||
setStep("waiting");
|
||||
@@ -277,6 +286,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
|
||||
setAuthData({ ...data, redirectUri, codexServerSide, xaiServerSide });
|
||||
|
||||
// Guard: device_code providers return authUrl:null from /authorize. Never window.open(null)
|
||||
// (browsers coerce it to the relative path ".../null").
|
||||
if (!data.authUrl) {
|
||||
if (data.flowType === "device_code") {
|
||||
throw new Error(
|
||||
`Provider ${provider} uses device-code login but is not wired in the OAuth modal device-code list`
|
||||
);
|
||||
}
|
||||
throw new Error("No authorization URL returned from OAuth provider");
|
||||
}
|
||||
|
||||
if (provider === "codex" && codexProxyActive) {
|
||||
// Proxy active: callback will be handled server-side (auto-exchange) or via channels (fallback)
|
||||
setStep("waiting");
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ConfirmModal } from "./Modal";
|
||||
import NineRemotePromoModal from "./NineRemotePromoModal";
|
||||
|
||||
// const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"];
|
||||
const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts", "stt"];
|
||||
const VISIBLE_MEDIA_KINDS = ["embedding", "image", "video", "tts", "stt"];
|
||||
// Combined entry: webSearch + webFetch share one page at /dashboard/media-providers/web
|
||||
const COMBINED_WEB_ITEM = { id: "web", label: "Web Fetch & Search", icon: "travel_explore", href: "/dashboard/media-providers/web" };
|
||||
|
||||
@@ -25,6 +25,7 @@ const navItems = [
|
||||
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
|
||||
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
|
||||
{ href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" },
|
||||
// { href: "/dashboard/pxpipe", label: "PXPIPE", icon: "image" },
|
||||
{ href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" },
|
||||
];
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import Badge from "./Badge";
|
||||
import Card from "./Card";
|
||||
import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards";
|
||||
import UsageTable, { fmt, fmtTime } from "@/app/(dashboard)/dashboard/usage/components/UsageTable";
|
||||
import ProviderTopology from "@/app/(dashboard)/dashboard/usage/components/ProviderTopology";
|
||||
import dynamic from "next/dynamic";
|
||||
// Lazy-load: keeps @xyflow/react out of the shared bundle until topology renders
|
||||
const ProviderTopology = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/ProviderTopology"), { ssr: false });
|
||||
import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart";
|
||||
|
||||
function timeAgo(timestamp) {
|
||||
|
||||
@@ -37,6 +37,7 @@ export { default as SegmentedControl } from "./SegmentedControl";
|
||||
export { default as Tooltip } from "./Tooltip";
|
||||
export { default as ProviderInfoCard } from "./ProviderInfoCard";
|
||||
export { default as CapacityBadges } from "./CapacityBadges";
|
||||
export { default as ApiExplorerModal } from "./ApiExplorerModal";
|
||||
|
||||
// Layouts
|
||||
export * from "./layouts";
|
||||
|
||||
871
src/shared/constants/apiCatalog.js
Normal file
871
src/shared/constants/apiCatalog.js
Normal file
@@ -0,0 +1,871 @@
|
||||
/**
|
||||
* Public AI API catalog for the dashboard API Explorer.
|
||||
* Single source of truth for endpoint docs + form fields used by ApiExplorerModal.
|
||||
*/
|
||||
|
||||
/** @typedef {"string"|"number"|"boolean"|"select"|"textarea"|"json"|"file"|"messages"} FieldType */
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApiField
|
||||
* @property {string} key
|
||||
* @property {string} label
|
||||
* @property {FieldType} type
|
||||
* @property {boolean} [required]
|
||||
* @property {string} [description]
|
||||
* @property {*} [default]
|
||||
* @property {string[]} [options]
|
||||
* @property {number} [min]
|
||||
* @property {number} [max]
|
||||
* @property {number} [step]
|
||||
* @property {string} [placeholder]
|
||||
* @property {string} [accept] - file accept attr
|
||||
* @property {"body"|"query"|"header"} [location] - default body
|
||||
* @property {boolean} [advanced] - hide behind "Advanced"
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApiEndpoint
|
||||
* @property {string} id
|
||||
* @property {string} method
|
||||
* @property {string} path
|
||||
* @property {string} label
|
||||
* @property {string} description
|
||||
* @property {string} icon
|
||||
* @property {string} category
|
||||
* @property {"json"|"multipart"|"none"} contentType
|
||||
* @property {boolean} [needsModel]
|
||||
* @property {string} [modelsKind] - /v1/models/{kind} query
|
||||
* @property {ApiField[]} fields
|
||||
* @property {string} [defaultResponse]
|
||||
* @property {boolean} [supportsStream]
|
||||
*/
|
||||
|
||||
/** @type {ApiEndpoint[]} */
|
||||
export const API_CATALOG = [
|
||||
{
|
||||
id: "models",
|
||||
method: "GET",
|
||||
path: "/v1/models",
|
||||
label: "List Models",
|
||||
description: "List chat/LLM models (default). Combos appear with owned_by: combo.",
|
||||
icon: "list_alt",
|
||||
category: "Discovery",
|
||||
contentType: "none",
|
||||
fields: [],
|
||||
defaultResponse: `{\n "object": "list",\n "data": [\n { "id": "openai/gpt-4o", "object": "model", "owned_by": "openai" }\n ]\n}`,
|
||||
},
|
||||
{
|
||||
id: "models-kind",
|
||||
method: "GET",
|
||||
path: "/v1/models/{kind}",
|
||||
label: "List Models by Kind",
|
||||
description: "List models for a capability kind: image, tts, stt, embedding, web, etc.",
|
||||
icon: "category",
|
||||
category: "Discovery",
|
||||
contentType: "none",
|
||||
fields: [
|
||||
{
|
||||
key: "kind",
|
||||
label: "Kind",
|
||||
type: "select",
|
||||
required: true,
|
||||
location: "path",
|
||||
default: "image",
|
||||
options: ["image", "tts", "stt", "embedding", "web", "image-to-text"],
|
||||
description: "Path segment: /v1/models/{kind}. Chat models use GET /v1/models (no kind).",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "object": "list",\n "data": [{ "id": "openai/dall-e-3", "object": "model", "owned_by": "openai" }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "models-info",
|
||||
method: "GET",
|
||||
path: "/v1/models/info",
|
||||
label: "Model Info",
|
||||
description: "Per-model metadata: params, options, contextWindow, capabilities.",
|
||||
icon: "info",
|
||||
category: "Discovery",
|
||||
contentType: "none",
|
||||
fields: [
|
||||
{
|
||||
key: "id",
|
||||
label: "Model ID",
|
||||
type: "string",
|
||||
required: true,
|
||||
location: "query",
|
||||
placeholder: "openai/gpt-4o",
|
||||
description: "Query: ?id=provider/model",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "id": "openai/gpt-4o",\n "kind": "llm",\n "endpoint": "/v1/chat/completions",\n "contextWindow": 128000\n}`,
|
||||
},
|
||||
{
|
||||
id: "chat-completions",
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
label: "Chat Completions",
|
||||
description: "OpenAI-compatible chat/code generation with streaming + auto-fallback.",
|
||||
icon: "chat",
|
||||
category: "Chat",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "llm",
|
||||
supportsStream: true,
|
||||
fields: [
|
||||
{
|
||||
key: "messages",
|
||||
label: "Messages",
|
||||
type: "messages",
|
||||
required: true,
|
||||
default: [{ role: "user", content: "Hello! Say hi in one sentence." }],
|
||||
description: "Array of { role, content }",
|
||||
},
|
||||
{
|
||||
key: "stream",
|
||||
label: "Stream",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "SSE streaming response",
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "top_p",
|
||||
label: "Top P",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "max_tokens",
|
||||
label: "Max Tokens",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "max_completion_tokens",
|
||||
label: "Max Completion Tokens",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "presence_penalty",
|
||||
label: "Presence Penalty",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "frequency_penalty",
|
||||
label: "Frequency Penalty",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "seed",
|
||||
label: "Seed",
|
||||
type: "number",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "stop",
|
||||
label: "Stop",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '["\\n"] or "stop"',
|
||||
advanced: true,
|
||||
description: "String or array of stop sequences",
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
label: "Tools",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: "[{ type: \"function\", function: {...} }]",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "tool_choice",
|
||||
label: "Tool Choice",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '"auto" | "none" | { type, function }',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "response_format",
|
||||
label: "Response Format",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '{ "type": "json_object" }',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "reasoning_effort",
|
||||
label: "Reasoning Effort",
|
||||
type: "select",
|
||||
default: "",
|
||||
options: ["", "low", "medium", "high"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "thinking",
|
||||
label: "Thinking",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '{ "type": "enabled", "budget_tokens": 1024 }',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "n",
|
||||
label: "n",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
max: 8,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
label: "User",
|
||||
type: "string",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "id": "chatcmpl-...",\n "object": "chat.completion",\n "choices": [{ "message": { "role": "assistant", "content": "..." }, "finish_reason": "stop" }],\n "usage": { "prompt_tokens": 8, "completion_tokens": 2, "total_tokens": 10 }\n}`,
|
||||
},
|
||||
{
|
||||
id: "messages",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
label: "Messages (Anthropic)",
|
||||
description: "Anthropic Messages API format. Same models, Claude-compatible tools/clients.",
|
||||
icon: "forum",
|
||||
category: "Chat",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "llm",
|
||||
supportsStream: true,
|
||||
fields: [
|
||||
{
|
||||
key: "messages",
|
||||
label: "Messages",
|
||||
type: "messages",
|
||||
required: true,
|
||||
default: [{ role: "user", content: "Hello! Say hi in one sentence." }],
|
||||
},
|
||||
{
|
||||
key: "max_tokens",
|
||||
label: "Max Tokens",
|
||||
type: "number",
|
||||
required: true,
|
||||
default: 1024,
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
key: "stream",
|
||||
label: "Stream",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: "system",
|
||||
label: "System",
|
||||
type: "textarea",
|
||||
default: "",
|
||||
placeholder: "You are a helpful assistant.",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "top_p",
|
||||
label: "Top P",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "top_k",
|
||||
label: "Top K",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "stop_sequences",
|
||||
label: "Stop Sequences",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '["\\n\\nHuman:"]',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
label: "Tools",
|
||||
type: "json",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "tool_choice",
|
||||
label: "Tool Choice",
|
||||
type: "json",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "thinking",
|
||||
label: "Thinking",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '{ "type": "enabled", "budget_tokens": 1024 }',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "anthropic-version",
|
||||
label: "anthropic-version",
|
||||
type: "string",
|
||||
location: "header",
|
||||
default: "2023-06-01",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "id": "msg_...",\n "type": "message",\n "role": "assistant",\n "content": [{ "type": "text", "text": "Hello!" }],\n "stop_reason": "end_turn",\n "usage": { "input_tokens": 8, "output_tokens": 2 }\n}`,
|
||||
},
|
||||
{
|
||||
id: "responses",
|
||||
method: "POST",
|
||||
path: "/v1/responses",
|
||||
label: "Responses API",
|
||||
description: "OpenAI Responses format (Codex / OpenClaw). input can be string or items[].",
|
||||
icon: "reply",
|
||||
category: "Chat",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "llm",
|
||||
supportsStream: true,
|
||||
fields: [
|
||||
{
|
||||
key: "input",
|
||||
label: "Input",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "Hello! Say hi in one sentence.",
|
||||
description: "String or JSON array of input items",
|
||||
},
|
||||
{
|
||||
key: "stream",
|
||||
label: "Stream",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: "instructions",
|
||||
label: "Instructions",
|
||||
type: "textarea",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "max_output_tokens",
|
||||
label: "Max Output Tokens",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
label: "Tools",
|
||||
type: "json",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "reasoning",
|
||||
label: "Reasoning",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '{ "effort": "medium" }',
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "id": "resp_...",\n "object": "response",\n "status": "completed",\n "output": [{ "type": "message", "content": [{ "type": "output_text", "text": "..." }] }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "count-tokens",
|
||||
method: "POST",
|
||||
path: "/v1/messages/count_tokens",
|
||||
label: "Count Tokens",
|
||||
description: "Estimate token count for Anthropic-style messages (local estimate).",
|
||||
icon: "tag",
|
||||
category: "Chat",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "llm",
|
||||
fields: [
|
||||
{
|
||||
key: "messages",
|
||||
label: "Messages",
|
||||
type: "messages",
|
||||
required: true,
|
||||
default: [{ role: "user", content: "Hello world" }],
|
||||
},
|
||||
{
|
||||
key: "system",
|
||||
label: "System",
|
||||
type: "textarea",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "input_tokens": 12\n}`,
|
||||
},
|
||||
{
|
||||
id: "embeddings",
|
||||
method: "POST",
|
||||
path: "/v1/embeddings",
|
||||
label: "Embeddings",
|
||||
description: "Vector embeddings for RAG / semantic search.",
|
||||
icon: "scatter_plot",
|
||||
category: "Embeddings",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "embedding",
|
||||
fields: [
|
||||
{
|
||||
key: "input",
|
||||
label: "Input",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "Hello world",
|
||||
description: "String or JSON array of strings",
|
||||
},
|
||||
{
|
||||
key: "encoding_format",
|
||||
label: "Encoding",
|
||||
type: "select",
|
||||
default: "float",
|
||||
options: ["float", "base64"],
|
||||
},
|
||||
{
|
||||
key: "dimensions",
|
||||
label: "Dimensions",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
advanced: true,
|
||||
description: "OpenAI text-embedding-3-* only",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "object": "list",\n "data": [{ "object": "embedding", "index": 0, "embedding": [0.01, -0.02, "..."] }],\n "usage": { "prompt_tokens": 2, "total_tokens": 2 }\n}`,
|
||||
},
|
||||
{
|
||||
id: "images-generations",
|
||||
method: "POST",
|
||||
path: "/v1/images/generations",
|
||||
label: "Image Generation",
|
||||
description: "Text-to-image (DALL·E, Imagen, FLUX, Codex, MiniMax…). Supports binary output.",
|
||||
icon: "brush",
|
||||
category: "Media",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "image",
|
||||
fields: [
|
||||
{
|
||||
key: "prompt",
|
||||
label: "Prompt",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "A cute cat wearing a hat, watercolor style",
|
||||
},
|
||||
{
|
||||
key: "n",
|
||||
label: "n",
|
||||
type: "number",
|
||||
default: 1,
|
||||
min: 1,
|
||||
max: 4,
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
label: "Size",
|
||||
type: "select",
|
||||
default: "auto",
|
||||
options: ["auto", "1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"],
|
||||
},
|
||||
{
|
||||
key: "aspect_ratio",
|
||||
label: "Aspect Ratio",
|
||||
type: "select",
|
||||
default: "",
|
||||
options: ["", "auto", "1:1", "16:9", "9:16", "4:3", "3:2", "2:3", "9:19.5", "20:9"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "resolution",
|
||||
label: "Resolution",
|
||||
type: "select",
|
||||
default: "",
|
||||
options: ["", "1k", "2k"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "quality",
|
||||
label: "Quality",
|
||||
type: "select",
|
||||
default: "auto",
|
||||
options: ["auto", "low", "medium", "high", "standard", "hd"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "background",
|
||||
label: "Background",
|
||||
type: "select",
|
||||
default: "auto",
|
||||
options: ["auto", "transparent", "opaque"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "style",
|
||||
label: "Style",
|
||||
type: "select",
|
||||
default: "",
|
||||
options: ["", "vivid", "natural"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "response_format",
|
||||
label: "Response Format",
|
||||
type: "select",
|
||||
default: "url",
|
||||
options: ["url", "b64_json", "binary"],
|
||||
description: "binary uses ?response_format=binary (raw image bytes)",
|
||||
},
|
||||
{
|
||||
key: "output_format",
|
||||
label: "Codec",
|
||||
type: "select",
|
||||
default: "png",
|
||||
options: ["png", "jpeg", "webp"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "image",
|
||||
label: "Reference Image",
|
||||
type: "image",
|
||||
default: "",
|
||||
placeholder: "https://... or upload an image",
|
||||
advanced: true,
|
||||
description: "Edit / img2img when provider supports it. Paste URL or upload file (stored as data URL).",
|
||||
accept: "image/*",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "created": 1735000000,\n "data": [{ "url": "https://..." }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "audio-speech",
|
||||
method: "POST",
|
||||
path: "/v1/audio/speech",
|
||||
label: "Text to Speech",
|
||||
description: "OpenAI / ElevenLabs / Edge / Google / Deepgram voices → audio bytes.",
|
||||
icon: "record_voice_over",
|
||||
category: "Media",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "tts",
|
||||
fields: [
|
||||
{
|
||||
key: "input",
|
||||
label: "Text",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "Hello from 9Router!",
|
||||
},
|
||||
{
|
||||
key: "response_format",
|
||||
label: "Response Format",
|
||||
type: "select",
|
||||
location: "query",
|
||||
default: "mp3",
|
||||
options: ["mp3", "json"],
|
||||
description: "mp3 = raw audio; json = { audio: base64, format }",
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
label: "Language Hint",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "en, vi, ...",
|
||||
advanced: true,
|
||||
description: "Optional language hint (e.g. Gemini)",
|
||||
},
|
||||
{
|
||||
key: "voice",
|
||||
label: "Voice",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "alloy (provider-dependent)",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "speed",
|
||||
label: "Speed",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0.25,
|
||||
max: 4,
|
||||
step: 0.05,
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: "(binary audio/mp3)",
|
||||
},
|
||||
{
|
||||
id: "audio-voices",
|
||||
method: "GET",
|
||||
path: "/v1/audio/voices",
|
||||
label: "List Voices",
|
||||
description: "List TTS voices for elevenlabs, edge-tts, deepgram, inworld, local-device.",
|
||||
icon: "voice_selection",
|
||||
category: "Media",
|
||||
contentType: "none",
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
type: "select",
|
||||
required: true,
|
||||
location: "query",
|
||||
default: "edge-tts",
|
||||
options: ["edge-tts", "elevenlabs", "deepgram", "inworld", "local-device"],
|
||||
},
|
||||
{
|
||||
key: "lang",
|
||||
label: "Language",
|
||||
type: "string",
|
||||
location: "query",
|
||||
default: "",
|
||||
placeholder: "vi, en, ...",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "object": "list",\n "data": [{ "id": "...", "model": "edge-tts/vi-VN-HoaiMyNeural" }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "audio-transcriptions",
|
||||
method: "POST",
|
||||
path: "/v1/audio/transcriptions",
|
||||
label: "Speech to Text",
|
||||
description: "Whisper-compatible multipart transcription.",
|
||||
icon: "mic",
|
||||
category: "Media",
|
||||
contentType: "multipart",
|
||||
needsModel: true,
|
||||
modelsKind: "stt",
|
||||
fields: [
|
||||
{
|
||||
key: "file",
|
||||
label: "Audio File",
|
||||
type: "file",
|
||||
required: true,
|
||||
accept: "audio/*,.mp3,.wav,.m4a,.webm,.ogg,.flac",
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
label: "Language",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "en, vi, ...",
|
||||
},
|
||||
{
|
||||
key: "prompt",
|
||||
label: "Prompt",
|
||||
type: "textarea",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "response_format",
|
||||
label: "Response Format",
|
||||
type: "select",
|
||||
default: "json",
|
||||
options: ["json", "text", "verbose_json", "srt", "vtt"],
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "text": "..."\n}`,
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
method: "POST",
|
||||
path: "/v1/search",
|
||||
label: "Web Search",
|
||||
description: "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com…",
|
||||
icon: "search",
|
||||
category: "Web",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "web",
|
||||
fields: [
|
||||
{
|
||||
key: "query",
|
||||
label: "Query",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "What is the latest news about AI?",
|
||||
},
|
||||
{
|
||||
key: "max_results",
|
||||
label: "Max Results",
|
||||
type: "number",
|
||||
default: 5,
|
||||
min: 1,
|
||||
max: 100,
|
||||
},
|
||||
{
|
||||
key: "search_type",
|
||||
label: "Search Type",
|
||||
type: "select",
|
||||
default: "web",
|
||||
options: ["web", "news"],
|
||||
},
|
||||
{
|
||||
key: "country",
|
||||
label: "Country",
|
||||
type: "string",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
label: "Language",
|
||||
type: "string",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "time_range",
|
||||
label: "Time Range",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "day, week, month, year",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "domain_filter",
|
||||
label: "Domain Filter",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "example.com",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "provider": "tavily",\n "query": "...",\n "results": [{ "title": "...", "url": "...", "snippet": "..." }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "web-fetch",
|
||||
method: "POST",
|
||||
path: "/v1/web/fetch",
|
||||
label: "Web Fetch",
|
||||
description: "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.",
|
||||
icon: "language",
|
||||
category: "Web",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "web",
|
||||
fields: [
|
||||
{
|
||||
key: "url",
|
||||
label: "URL",
|
||||
type: "string",
|
||||
required: true,
|
||||
default: "https://9router.com",
|
||||
placeholder: "https://example.com",
|
||||
},
|
||||
{
|
||||
key: "format",
|
||||
label: "Format",
|
||||
type: "select",
|
||||
default: "markdown",
|
||||
options: ["markdown", "text", "html"],
|
||||
},
|
||||
{
|
||||
key: "max_characters",
|
||||
label: "Max Characters",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
advanced: true,
|
||||
description: "0 = no truncate",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "provider": "jina-reader",\n "url": "...",\n "title": "...",\n "content": { "format": "markdown", "text": "..." }\n}`,
|
||||
},
|
||||
];
|
||||
|
||||
export const API_CATEGORIES = [...new Set(API_CATALOG.map((e) => e.category))];
|
||||
|
||||
export function getApiEndpointById(id) {
|
||||
return API_CATALOG.find((e) => e.id === id) || null;
|
||||
}
|
||||
|
||||
export function getApiEndpointsByCategory(category) {
|
||||
return API_CATALOG.filter((e) => e.category === category);
|
||||
}
|
||||
@@ -62,6 +62,9 @@ export const MITM_TOOLS = {
|
||||
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" },
|
||||
{ id: "deepseek-3.2", name: "DeepSeek 3.2", alias: "deepseek-3.2" },
|
||||
{ id: "minimax-m2.1", name: "MiniMax M2.1", alias: "minimax-m2.1" },
|
||||
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", alias: "gpt-5.6-sol", contextLength: 272000, rateMultiplier: 2.4 },
|
||||
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra", alias: "gpt-5.6-terra", contextLength: 272000, rateMultiplier: 1.2 },
|
||||
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", alias: "gpt-5.6-luna", contextLength: 272000, rateMultiplier: 0.6 },
|
||||
{ id: "simple-task", name: "Qwen3 Coder Next", alias: "simple-task" },
|
||||
],
|
||||
},
|
||||
@@ -95,13 +98,15 @@ export const CLI_TOOLS = {
|
||||
model: "ANTHROPIC_MODEL",
|
||||
opusModel: "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
sonnetModel: "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
fableModel: "ANTHROPIC_DEFAULT_FABLE_MODEL",
|
||||
haikuModel: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
},
|
||||
modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"],
|
||||
modelAliases: ["default", "sonnet", "opus", "fable", "haiku", "opusplan"],
|
||||
settingsFile: "~/.claude/settings.json",
|
||||
defaultModels: [
|
||||
{ id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-6" },
|
||||
{ id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-6" },
|
||||
{ id: "fable", name: "Claude Fable", alias: "fable", envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", defaultValue: "cc/claude-fable-5" },
|
||||
{ id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-8" },
|
||||
{ id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-5" },
|
||||
{ id: "haiku", name: "Claude Haiku", alias: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
|
||||
],
|
||||
},
|
||||
@@ -358,6 +363,30 @@ amp --model "{{model}}"
|
||||
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", alias: "gemini", defaultValue: "gemini/gemini-3.1-pro" },
|
||||
],
|
||||
},
|
||||
"grok-build": {
|
||||
id: "grok-build",
|
||||
name: "Grok Build",
|
||||
image: "/providers/grok-cli.png",
|
||||
color: "#1DA1F2",
|
||||
description: "xAI Grok Build TUI coding agent",
|
||||
configType: "custom",
|
||||
docsUrl: "https://x.ai/cli",
|
||||
defaultCommand: "grok",
|
||||
notes: [
|
||||
{
|
||||
type: "info",
|
||||
text: "Grok Build uses ~/.grok/config.toml. 9Router writes a [model.9router] custom model and sets it as the default.",
|
||||
},
|
||||
{
|
||||
type: "info",
|
||||
text: "After Apply, run grok (or /model 9router) to use the routed model. Switch back anytime with /model grok-build.",
|
||||
},
|
||||
{
|
||||
type: "warning",
|
||||
text: "Config path: Linux/macOS ~/.grok/config.toml • Windows %USERPROFILE%\\.grok\\config.toml",
|
||||
},
|
||||
],
|
||||
},
|
||||
// HIDDEN: gemini-cli
|
||||
// "gemini-cli": {
|
||||
// id: "gemini-cli",
|
||||
|
||||
@@ -33,4 +33,5 @@ export const LOCALE_FLAGS = {
|
||||
"fi": "🇫🇮",
|
||||
"da": "🇩🇰",
|
||||
"no": "🇳🇴",
|
||||
"fa": "🇮🇷",
|
||||
};
|
||||
|
||||
@@ -77,7 +77,7 @@ export const MEDIA_PROVIDER_KINDS = [
|
||||
{ id: "stt", label: "Speech To Text", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } },
|
||||
{ id: "webSearch", label: "Web Search", icon: "travel_explore", endpoint: { method: "POST", path: "/v1/search" } },
|
||||
{ id: "webFetch", label: "Web Fetch", icon: "language", endpoint: { method: "POST", path: "/v1/web/fetch" } },
|
||||
{ id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/video/generations" } },
|
||||
{ id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/videos/generations" } },
|
||||
{ id: "music", label: "Music", icon: "music_note", endpoint: { method: "POST", path: "/v1/audio/music" } },
|
||||
];
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS, VIRTUAL_IFACE_REGEX,
|
||||
} from "@/lib/tunnel";
|
||||
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
|
||||
import { startQuotaAutoPing } from "@/shared/services/quotaAutoPing";
|
||||
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
|
||||
import { killAllBridges } from "@/lib/mcp/stdioSseBridge";
|
||||
|
||||
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
|
||||
(function bootstrapMitm() {
|
||||
@@ -32,6 +32,10 @@ import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
|
||||
|
||||
process.setMaxListeners(20);
|
||||
|
||||
// Defer heavy startup work so the first HTTP request (login → dashboard) isn't
|
||||
// starved by DB cleanup, cloudflared download, lsof/DNS probes and OAuth pings.
|
||||
const STARTUP_DEFER_MS = 3000;
|
||||
|
||||
// Survive Next.js hot reload
|
||||
const g = global.__appSingleton ??= {
|
||||
signalHandlersRegistered: false,
|
||||
@@ -47,26 +51,12 @@ const g = global.__appSingleton ??= {
|
||||
|
||||
export async function initializeApp() {
|
||||
try {
|
||||
await cleanupProviderConnections();
|
||||
const settings = await getSettings();
|
||||
|
||||
// Auto-resume tunnel (once per process)
|
||||
if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
|
||||
g.tunnelAutoResumed = true;
|
||||
console.log("[InitApp] Tunnel was enabled, auto-resuming...");
|
||||
safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
|
||||
}
|
||||
|
||||
// Auto-resume tailscale (once per process)
|
||||
if (settings.tailscaleEnabled && !g.tailscaleAutoResumed) {
|
||||
g.tailscaleAutoResumed = true;
|
||||
console.log("[InitApp] Tailscale was enabled, auto-resuming...");
|
||||
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
|
||||
}
|
||||
|
||||
// Register cleanup + exit-respawn callback immediately so signals and
|
||||
// unexpected cloudflared exits are handled even during the deferred window.
|
||||
if (!g.signalHandlersRegistered) {
|
||||
const cleanup = () => {
|
||||
try { removeAllDNSEntriesSync(); } catch { /* best effort */ }
|
||||
try { killAllBridges(); } catch { /* best effort */ }
|
||||
killCloudflared();
|
||||
process.exit();
|
||||
};
|
||||
@@ -76,30 +66,63 @@ export async function initializeApp() {
|
||||
g.signalHandlersRegistered = true;
|
||||
}
|
||||
|
||||
ensureCloudflared().catch(() => {});
|
||||
|
||||
// Sync mitmAlias DB → JSON cache so standalone MITM server can read it
|
||||
syncMitmAliasCache().catch(() => {});
|
||||
|
||||
// Auto-respawn tunnel when cloudflared exits unexpectedly (e.g. network change drop)
|
||||
setTunnelUnexpectedExitCallback(() => {
|
||||
safeRestartTunnel("unexpected-exit").catch(() => {});
|
||||
});
|
||||
|
||||
startWatchdog();
|
||||
startNetworkMonitor();
|
||||
autoStartMitm();
|
||||
startQuotaAutoPing();
|
||||
// Defer the heavy work — nothing here blocks incoming requests.
|
||||
setTimeout(() => {
|
||||
runHeavyStartup().catch((e) => console.error("[InitApp] deferred startup failed:", e.message));
|
||||
}, STARTUP_DEFER_MS);
|
||||
} catch (error) {
|
||||
console.error("[InitApp] Error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function autoStartMitm() {
|
||||
async function runHeavyStartup() {
|
||||
await cleanupProviderConnections();
|
||||
const settings = await getSettings();
|
||||
|
||||
// Auto-resume tunnel (once per process)
|
||||
if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
|
||||
g.tunnelAutoResumed = true;
|
||||
console.log("[InitApp] Tunnel was enabled, auto-resuming...");
|
||||
safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
|
||||
}
|
||||
|
||||
// Auto-resume tailscale (once per process)
|
||||
if (settings.tailscaleEnabled && !g.tailscaleAutoResumed) {
|
||||
g.tailscaleAutoResumed = true;
|
||||
console.log("[InitApp] Tailscale was enabled, auto-resuming...");
|
||||
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
|
||||
}
|
||||
|
||||
if (settings.tunnelEnabled) ensureCloudflared().catch(() => {});
|
||||
|
||||
if (settings.mitmEnabled) {
|
||||
// Sync mitmAlias DB → JSON cache so standalone MITM server can read it.
|
||||
syncMitmAliasCache().catch(() => {});
|
||||
autoStartMitm(settings);
|
||||
}
|
||||
|
||||
configureTunnelMonitoring(settings);
|
||||
|
||||
if (hasQuotaAutoPingEnabled(settings)) {
|
||||
import("@/shared/services/quotaAutoPing")
|
||||
.then(({ startQuotaAutoPing }) => startQuotaAutoPing())
|
||||
.catch((e) => console.log("[AutoPing] scheduler start failed:", e.message));
|
||||
}
|
||||
}
|
||||
|
||||
function hasQuotaAutoPingEnabled(settings) {
|
||||
return [settings?.claudeAutoPing, settings?.codexAutoPing]
|
||||
.some((config) => Object.values(config?.connections || {}).some(Boolean));
|
||||
}
|
||||
|
||||
async function autoStartMitm(settings) {
|
||||
if (g.mitmStartInProgress) return;
|
||||
g.mitmStartInProgress = true;
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (!settings.mitmEnabled) return;
|
||||
const mitmStatus = await getMitmStatus();
|
||||
if (mitmStatus.running) return;
|
||||
@@ -218,6 +241,12 @@ function startWatchdog() {
|
||||
if (g.watchdogInterval.unref) g.watchdogInterval.unref();
|
||||
}
|
||||
|
||||
function stopWatchdog() {
|
||||
if (!g.watchdogInterval) return;
|
||||
clearInterval(g.watchdogInterval);
|
||||
g.watchdogInterval = null;
|
||||
}
|
||||
|
||||
// ─── Network monitor: detect IPv4 fingerprint change + sleep/wake ────────────
|
||||
|
||||
function getNetworkFingerprint() {
|
||||
@@ -279,4 +308,23 @@ function startNetworkMonitor() {
|
||||
if (g.networkMonitorInterval.unref) g.networkMonitorInterval.unref();
|
||||
}
|
||||
|
||||
|
||||
function stopNetworkMonitor() {
|
||||
if (!g.networkMonitorInterval) return;
|
||||
clearInterval(g.networkMonitorInterval);
|
||||
g.networkMonitorInterval = null;
|
||||
g.lastNetworkFingerprint = null;
|
||||
g.lastOnline = null;
|
||||
}
|
||||
|
||||
export function configureTunnelMonitoring(settings) {
|
||||
if (settings?.tunnelEnabled || settings?.tailscaleEnabled) {
|
||||
startWatchdog();
|
||||
startNetworkMonitor();
|
||||
return;
|
||||
}
|
||||
stopWatchdog();
|
||||
stopNetworkMonitor();
|
||||
}
|
||||
|
||||
export default initializeApp;
|
||||
|
||||
@@ -296,3 +296,18 @@ export function startQuotaAutoPing() {
|
||||
g.interval = setInterval(() => { runQuotaAutoPingTick().catch(() => {}); }, C.tickIntervalMs);
|
||||
if (g.interval.unref) g.interval.unref();
|
||||
}
|
||||
|
||||
export function stopQuotaAutoPing() {
|
||||
if (!g.interval) return;
|
||||
clearInterval(g.interval);
|
||||
g.interval = null;
|
||||
console.log("[AutoPing] scheduler stopped");
|
||||
}
|
||||
|
||||
export function configureQuotaAutoPing(settings) {
|
||||
const enabled = Object.values(C.providers).some((providerConfig) =>
|
||||
Object.values(settings?.[providerConfig.settingsKey]?.connections || {}).some(Boolean)
|
||||
);
|
||||
if (enabled) startQuotaAutoPing();
|
||||
else stopQuotaAutoPing();
|
||||
}
|
||||
|
||||
94
src/shared/utils/bulkAdd.js
Normal file
94
src/shared/utils/bulkAdd.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Bulk-add API-key planner.
|
||||
//
|
||||
// Background: the backend upserts apikey connections BY NAME
|
||||
// (src/lib/db/repos/connectionsRepo.js ~L144: existing = all.find(c =>
|
||||
// c.authType === "apikey" && c.name === data.name)). A colliding name
|
||||
// overwrites an existing key instead of inserting a new one. Bulk-add used to
|
||||
// derive "<base> <lineIndex>" from the paste position, blind to existing
|
||||
// names, so re-adding keys often silently replaced earlier ones.
|
||||
//
|
||||
// This planner gap-fills the smallest free "<base> <n>" against both existing
|
||||
// connection names and names already assigned earlier in the same batch, so a
|
||||
// generated name is never reused and the backend always inserts.
|
||||
//
|
||||
// ponytail: only numeric-suffix collision is handled. A user who manually
|
||||
// types an exact existing non-numbered custom name (no index) will still hit
|
||||
// the backend upsert — but bulk auto-naming always appends " <n>", so this
|
||||
// path is unreachable from the bulk modal. Upgrade path: a backend
|
||||
// "skip-if-exists" flag on POST /api/providers if single-add ever needs it.
|
||||
|
||||
/**
|
||||
* Parse one pipe-separated bulk line into { baseName, apiKey, providerSpecificData? }.
|
||||
* @param {string} line
|
||||
* @param {{isCloudflareAi?: boolean}} [opts]
|
||||
* @returns {{baseName: string, apiKey: string, providerSpecificData?: object}|null}
|
||||
*/
|
||||
function parseLine(line, opts = {}) {
|
||||
const { isCloudflareAi = false } = opts;
|
||||
const parts = line.split("|");
|
||||
|
||||
if (isCloudflareAi && parts.length >= 3) {
|
||||
// name|apiKey|accountId (apiKey may itself contain pipes)
|
||||
const baseName = parts[0].trim();
|
||||
const apiKey = parts.slice(1, -1).join("|").trim();
|
||||
const accountId = parts[parts.length - 1].trim();
|
||||
return {
|
||||
baseName: baseName || "Key",
|
||||
apiKey,
|
||||
providerSpecificData: { accountId },
|
||||
};
|
||||
}
|
||||
|
||||
if (parts.length >= 2) {
|
||||
// name|apiKey (apiKey may itself contain pipes)
|
||||
const baseName = parts[0].trim();
|
||||
const apiKey = parts.slice(1).join("|").trim();
|
||||
return { baseName: baseName || "Key", apiKey };
|
||||
}
|
||||
|
||||
// apiKey only — auto-named "Key N"
|
||||
const apiKey = parts[0].trim();
|
||||
return { baseName: "Key", apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan a bulk add: parse lines, assign collision-free "<base> <n>" names.
|
||||
*
|
||||
* @param {string[]} lines raw paste lines
|
||||
* @param {string[]|null|undefined} existingNames connection names already saved
|
||||
* @param {{isCloudflareAi?: boolean}} [opts]
|
||||
* @returns {{name: string, apiKey: string, skipped: boolean, providerSpecificData?: object}[]}
|
||||
*/
|
||||
export function planBulkAdd(lines, existingNames, opts = {}) {
|
||||
const { isCloudflareAi = false } = opts;
|
||||
|
||||
const safeExisting = Array.isArray(existingNames) ? existingNames : [];
|
||||
const used = new Set(safeExisting.map((n) => (typeof n === "string" ? n.toLowerCase() : "")));
|
||||
|
||||
const out = [];
|
||||
for (const raw of lines) {
|
||||
const line = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!line) continue;
|
||||
|
||||
const parsed = parseLine(line, { isCloudflareAi });
|
||||
if (!parsed || !parsed.apiKey) continue;
|
||||
|
||||
const base = parsed.baseName;
|
||||
|
||||
// Gap-fill from 1: smallest free "<base> <n>" not in `used`.
|
||||
// O(batch * existing) — fine for bulk add (tens to low hundreds of keys).
|
||||
let idx = 1;
|
||||
let name;
|
||||
for (;;) {
|
||||
name = `${base} ${idx}`;
|
||||
if (!used.has(name.toLowerCase())) break;
|
||||
idx += 1;
|
||||
}
|
||||
used.add(name.toLowerCase());
|
||||
|
||||
const entry = { name, apiKey: parsed.apiKey, skipped: false };
|
||||
if (parsed.providerSpecificData) entry.providerSpecificData = parsed.providerSpecificData;
|
||||
out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user