diff --git a/open-sse/config/cliFingerprints.ts b/open-sse/config/cliFingerprints.ts new file mode 100644 index 0000000000..3f34399ffd --- /dev/null +++ b/open-sse/config/cliFingerprints.ts @@ -0,0 +1,238 @@ +/** + * 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; +} + +/** + * Fingerprint registry - keyed by provider alias (lowercase). + * Based on mitmproxy traffic captures from native CLI tools. + */ +export const CLI_FINGERPRINTS: Record = { + 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>(obj: T, fieldOrder: string[]): T { + if (!fieldOrder?.length || !obj || typeof obj !== "object") return obj; + + const result: Record = {}; + 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, + headerOrder: string[] +): Record { + if (!headerOrder?.length || !headers) return headers; + + const result: Record = {}; + const remaining = new Map(); + + // Build case-insensitive lookup + const headerMap = new Map(); + 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, + body: unknown +): { headers: Record; 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, fingerprint.bodyFieldOrder) + : body; + + return { + headers: orderedHeaders, + bodyString: JSON.stringify(orderedBody), + }; +} + +/** + * Check if CLI compatibility mode is enabled for a provider. + * This reads from the settings database or environment variable. + */ +export function isCliCompatEnabled(provider: string): boolean { + // Check environment variable first: CLI_COMPAT_=1 + const envKey = `CLI_COMPAT_${provider?.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`; + if (process.env[envKey] === "1" || process.env[envKey] === "true") return true; + + // Global enable: CLI_COMPAT_ALL=1 + if (process.env.CLI_COMPAT_ALL === "1" || process.env.CLI_COMPAT_ALL === "true") return true; + + return false; +} diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 00a3e9260d..83e1f95f60 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,4 +1,5 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; type JsonRecord = Record; @@ -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; diff --git a/package-lock.json b/package-lock.json index 1433310cd3..c12779e1cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "2.0.7", + "version": "2.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "2.0.7", + "version": "2.0.8", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/src/app/(dashboard)/dashboard/playground/page.tsx b/src/app/(dashboard)/dashboard/playground/page.tsx new file mode 100644 index 0000000000..a8cee7aa5f --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/page.tsx @@ -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 = { + 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 = { + chat: "/v1/chat/completions", + responses: "/v1/responses", + images: "/v1/images/generations", + embeddings: "/v1/embeddings", +}; + +export default function PlaygroundPage() { + const [models, setModels] = useState([]); + const [providers, setProviders] = useState([]); + 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(null); + const [responseDuration, setResponseDuration] = useState(null); + const abortRef = useRef(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(); + 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 ( +
+ {/* Info Banner */} +
+ + science + +
+

Model Playground

+

+ Test any model directly from the dashboard. Pick a provider, model, and endpoint type, + then send a request to see the raw response. +

+
+
+ + {/* Controls */} + +
+ {/* Provider */} +
+ + handleModelChange(e.target.value)} + options={filteredModels} + className="w-full" + /> +
+ + {/* Endpoint */} +
+ +