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:
Ravi Tharuma
2026-04-13 18:10:09 +02:00
committed by GitHub
parent 5049cc6e1d
commit 3e3ea37aba
3 changed files with 235 additions and 11 deletions

View File

@@ -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;

View File

@@ -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,

View 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 });
}