mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
feat: CLIProxyAPI executor dual-mode — auto-detect Claude Code OAuth for deeper emulation (#1198)
Integrated into release/v3.6.5
This commit is contained in:
@@ -1,8 +1,31 @@
|
||||
import { BaseExecutor, mergeUpstreamExtraHeaders, mergeAbortSignals } from "./base.ts";
|
||||
/**
|
||||
* CLIProxyAPI Executor — routes requests to a local CLIProxyAPI instance.
|
||||
*
|
||||
* Always uses the OpenAI-compatible /v1/chat/completions endpoint. CLIProxyAPI
|
||||
* internally detects Claude models and routes them through its Claude executor
|
||||
* with full emulation (CCH signing, billing header, system prompt, uTLS,
|
||||
* multi-account rotation, device profile learning, etc.).
|
||||
*
|
||||
* The UI toggle (cliproxyapiMode in providerSpecificData) controls WHETHER
|
||||
* to use CLIProxyAPI as the backend, not the wire format. Response format
|
||||
* is always OpenAI-compatible, so chatCore's SSE parsing works unchanged.
|
||||
*
|
||||
* Activation:
|
||||
* 1. Per-provider upstream_proxy_config (mode=cliproxyapi or fallback)
|
||||
* 2. Per-connection cliproxyapiMode toggle in providerSpecificData (UI)
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseExecutor,
|
||||
mergeUpstreamExtraHeaders,
|
||||
mergeAbortSignals,
|
||||
type ProviderCredentials,
|
||||
} from "./base.ts";
|
||||
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
|
||||
const DEFAULT_PORT = 8317;
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 5000;
|
||||
|
||||
function resolveCliproxyapiBaseUrl(): string {
|
||||
const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST;
|
||||
@@ -12,6 +35,16 @@ function resolveCliproxyapiBaseUrl(): string {
|
||||
|
||||
export { resolveCliproxyapiBaseUrl };
|
||||
|
||||
/**
|
||||
* Check if a connection has CLIProxyAPI deep mode enabled via UI toggle.
|
||||
* Used by chatCore's resolveExecutorWithProxy to decide routing.
|
||||
*/
|
||||
export function isCliproxyapiDeepModeEnabled(
|
||||
providerSpecificData?: Record<string, unknown> | null
|
||||
): boolean {
|
||||
return providerSpecificData?.cliproxyapiMode === "claude-native";
|
||||
}
|
||||
|
||||
export class CliproxyapiExecutor extends BaseExecutor {
|
||||
private readonly upstreamBaseUrl: string;
|
||||
|
||||
@@ -25,20 +58,27 @@ export class CliproxyapiExecutor extends BaseExecutor {
|
||||
this.upstreamBaseUrl = effectiveBase;
|
||||
}
|
||||
|
||||
buildUrl(_model: string, _stream: boolean, _urlIndex = 0): string {
|
||||
buildUrl(
|
||||
_model: string,
|
||||
_stream: boolean,
|
||||
_urlIndex = 0,
|
||||
_credentials: ProviderCredentials | null = null
|
||||
): string {
|
||||
// Always OpenAI-compatible. CLIProxyAPI detects Claude models internally
|
||||
// and applies full emulation (CCH, billing header, system prompt, uTLS).
|
||||
return `${this.upstreamBaseUrl}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
buildHeaders(credentials: any, stream = true): Record<string, string> {
|
||||
buildHeaders(credentials: ProviderCredentials | null, stream = true): Record<string, string> {
|
||||
const key = credentials?.apiKey || credentials?.accessToken;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const key = credentials?.apiKey || credentials?.accessToken;
|
||||
if (key) {
|
||||
headers["Authorization"] = `Bearer ${key}`;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
@@ -46,23 +86,32 @@ export class CliproxyapiExecutor extends BaseExecutor {
|
||||
return headers;
|
||||
}
|
||||
|
||||
transformRequest(model: string, body: any, _stream: boolean, _credentials: any): any {
|
||||
if (body && typeof body === "object" && body.model !== model) {
|
||||
return { ...body, model };
|
||||
transformRequest(
|
||||
model: string,
|
||||
body: unknown,
|
||||
_stream: boolean,
|
||||
_credentials: ProviderCredentials | null
|
||||
): unknown {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
|
||||
const transformed = { ...(body as Record<string, unknown>) };
|
||||
if (transformed.model !== model) {
|
||||
transformed.model = model;
|
||||
}
|
||||
return body;
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
async execute(input: {
|
||||
model: string;
|
||||
body: unknown;
|
||||
stream: boolean;
|
||||
credentials: any;
|
||||
credentials: ProviderCredentials;
|
||||
signal?: AbortSignal | null;
|
||||
log?: any;
|
||||
upstreamExtraHeaders?: Record<string, string> | null;
|
||||
}) {
|
||||
const url = this.buildUrl(input.model, input.stream);
|
||||
const url = this.buildUrl(input.model, input.stream, 0, input.credentials);
|
||||
const headers = this.buildHeaders(input.credentials, input.stream);
|
||||
const transformedBody = this.transformRequest(
|
||||
input.model,
|
||||
@@ -77,6 +126,11 @@ export class CliproxyapiExecutor extends BaseExecutor {
|
||||
? mergeAbortSignals(input.signal, timeoutSignal)
|
||||
: timeoutSignal;
|
||||
|
||||
input.log?.info?.(
|
||||
"CPA",
|
||||
`CLIProxyAPI → ${url} (model: ${input.model})`
|
||||
);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
@@ -90,6 +144,29 @@ export class CliproxyapiExecutor extends BaseExecutor {
|
||||
|
||||
return { response, url, headers, transformedBody };
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check — verifies CLIProxyAPI is reachable.
|
||||
*/
|
||||
async healthCheck(): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${this.upstreamBaseUrl}/health`, {
|
||||
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
|
||||
});
|
||||
return {
|
||||
ok: res.ok,
|
||||
latencyMs: Date.now() - start,
|
||||
...(!res.ok ? { error: `HTTP ${res.status}` } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default CliproxyapiExecutor;
|
||||
|
||||
@@ -463,6 +463,9 @@ interface ConnectionRowProps {
|
||||
onToggleRateLimit: (enabled?: boolean) => void;
|
||||
onToggleCodex5h?: (enabled?: boolean) => void;
|
||||
onToggleCodexWeekly?: (enabled?: boolean) => void;
|
||||
isCcCompatible?: boolean;
|
||||
cliproxyapiEnabled?: boolean;
|
||||
onToggleCliproxyapiMode?: (enabled?: boolean) => void;
|
||||
onRetest: () => void;
|
||||
isRetesting?: boolean;
|
||||
onEdit: () => void;
|
||||
@@ -1335,6 +1338,63 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const [cpaProviderEnabled, setCpaProviderEnabled] = useState(false);
|
||||
|
||||
// Load upstream proxy config for this provider on mount
|
||||
useEffect(() => {
|
||||
if (!isCcCompatible) return;
|
||||
fetch(`/api/settings`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
// Check if this provider has CLIProxyAPI routing enabled
|
||||
// The upstream_proxy_config is synced via the settings API
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Also check via direct upstream proxy config lookup
|
||||
fetch(`/api/upstream-proxy/${providerId}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data?.enabled && (data.mode === "cliproxyapi" || data.mode === "fallback")) {
|
||||
setCpaProviderEnabled(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [isCcCompatible, providerId]);
|
||||
|
||||
const handleToggleCliproxyapiMode = async (_connectionId, enabled) => {
|
||||
try {
|
||||
// Write to upstream_proxy_config table which resolveExecutorWithProxy reads
|
||||
const res = await fetch(`/api/upstream-proxy/${providerId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: enabled ? "cliproxyapi" : "native",
|
||||
enabled: enabled,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data.error || "Failed to update CLIProxyAPI routing");
|
||||
return;
|
||||
}
|
||||
|
||||
setCpaProviderEnabled(enabled);
|
||||
notify.success(
|
||||
enabled
|
||||
? "Requests now route through CLIProxyAPI (deeper emulation)"
|
||||
: "Requests now use native OmniRoute (direct)"
|
||||
);
|
||||
} catch {
|
||||
notify.error("Failed to update CLIProxyAPI routing");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleCodexLimit = async (connectionId, field, enabled) => {
|
||||
try {
|
||||
const target = connections.find((connection) => connection.id === connectionId);
|
||||
@@ -2612,6 +2672,11 @@ export default function ProviderDetailPage() {
|
||||
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
|
||||
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
|
||||
isCodex={providerId === "codex"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
onToggleCliproxyapiMode={(enabled) =>
|
||||
handleToggleCliproxyapiMode(conn.id, enabled)
|
||||
}
|
||||
onToggleCodex5h={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "use5h", enabled)
|
||||
}
|
||||
@@ -4578,6 +4643,8 @@ function ConnectionRow({
|
||||
connection,
|
||||
isOAuth,
|
||||
isCodex,
|
||||
isCcCompatible,
|
||||
cliproxyapiEnabled,
|
||||
isFirst,
|
||||
isLast,
|
||||
onMoveUp,
|
||||
@@ -4586,6 +4653,7 @@ function ConnectionRow({
|
||||
onToggleRateLimit,
|
||||
onToggleCodex5h,
|
||||
onToggleCodexWeekly,
|
||||
onToggleCliproxyapiMode,
|
||||
onRetest,
|
||||
isRetesting,
|
||||
onEdit,
|
||||
@@ -4677,6 +4745,7 @@ function ConnectionRow({
|
||||
const normalizedCodexPolicy = normalizeCodexLimitPolicy(codexPolicy);
|
||||
const codex5hEnabled = normalizedCodexPolicy.use5h;
|
||||
const codexWeeklyEnabled = normalizedCodexPolicy.useWeekly;
|
||||
const cliproxyapiDeepMode = !!cliproxyapiEnabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -4766,6 +4835,26 @@ function ConnectionRow({
|
||||
<span className="material-symbols-outlined text-[13px]">shield</span>
|
||||
{rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")}
|
||||
</button>
|
||||
{isCcCompatible && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
onClick={() => onToggleCliproxyapiMode?.(!cliproxyapiDeepMode)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
cliproxyapiDeepMode
|
||||
? "bg-indigo-500/15 text-indigo-500 hover:bg-indigo-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title={cliproxyapiDeepMode
|
||||
? "Using CLIProxyAPI for deeper Claude Code emulation (uTLS, multi-account, device profiles)"
|
||||
: "Enable CLIProxyAPI backend for deeper Claude Code OAuth emulation"
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">swap_horiz</span>
|
||||
CPA {cliproxyapiDeepMode ? "ON" : "OFF"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isCodex && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
@@ -4955,6 +5044,9 @@ ConnectionRow.propTypes = {
|
||||
onToggleRateLimit: PropTypes.func.isRequired,
|
||||
onToggleCodex5h: PropTypes.func,
|
||||
onToggleCodexWeekly: PropTypes.func,
|
||||
isCcCompatible: PropTypes.bool,
|
||||
cliproxyapiEnabled: PropTypes.bool,
|
||||
onToggleCliproxyapiMode: PropTypes.func,
|
||||
onRetest: PropTypes.func.isRequired,
|
||||
isRetesting: PropTypes.bool,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
|
||||
55
src/app/api/upstream-proxy/[providerId]/route.ts
Normal file
55
src/app/api/upstream-proxy/[providerId]/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getUpstreamProxyConfig,
|
||||
upsertUpstreamProxyConfig,
|
||||
deleteUpstreamProxyConfig,
|
||||
} from "@/lib/db/upstreamProxy";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ providerId: string }> }
|
||||
) {
|
||||
const { providerId } = await params;
|
||||
if (!providerId) {
|
||||
return NextResponse.json({ error: "providerId required" }, { status: 400 });
|
||||
}
|
||||
const config = await getUpstreamProxyConfig(providerId);
|
||||
if (!config) {
|
||||
return NextResponse.json({ enabled: false, mode: "native" });
|
||||
}
|
||||
return NextResponse.json(config);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ providerId: string }> }
|
||||
) {
|
||||
const { providerId } = await params;
|
||||
if (!providerId) {
|
||||
return NextResponse.json({ error: "providerId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const mode = body.mode === "cliproxyapi" || body.mode === "fallback" ? body.mode : "native";
|
||||
const enabled = body.enabled !== false;
|
||||
|
||||
const config = await upsertUpstreamProxyConfig({
|
||||
providerId,
|
||||
mode,
|
||||
enabled,
|
||||
});
|
||||
|
||||
return NextResponse.json(config);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ providerId: string }> }
|
||||
) {
|
||||
const { providerId } = await params;
|
||||
if (!providerId) {
|
||||
return NextResponse.json({ error: "providerId required" }, { status: 400 });
|
||||
}
|
||||
const deleted = await deleteUpstreamProxyConfig(providerId);
|
||||
return NextResponse.json({ deleted });
|
||||
}
|
||||
Reference in New Issue
Block a user