diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 77ba640d38..83549c8334 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -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 | 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 { + buildHeaders(credentials: ProviderCredentials | null, stream = true): Record { + const key = credentials?.apiKey || credentials?.accessToken; + const headers: Record = { "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) }; + 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 | 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; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 383fd89f7a..4a1fe36567 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -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 (
shield {rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")} + {isCcCompatible && ( + <> + | + + + )} {isCodex && ( <> | @@ -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, diff --git a/src/app/api/upstream-proxy/[providerId]/route.ts b/src/app/api/upstream-proxy/[providerId]/route.ts new file mode 100644 index 0000000000..6e72920345 --- /dev/null +++ b/src/app/api/upstream-proxy/[providerId]/route.ts @@ -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 }); +}