mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
fix: merge PR #562 — MCP session management, Claude passthrough, OAuth modal, detectFormat fixes
Cherry-pick from codex/omniroute-fixes-20260324: - Replace MCP singleton transport with per-session architecture for Streamable HTTP - Fix Claude passthrough via OpenAI round-trip normalization - Add detectFormatFromEndpoint() for endpoint-aware format detection - Support raw code#state in OAuth modal for Claude Code remote auth - Expose cloudConfigured/cloudUrl/machineId in settings API - Switch docker-compose.prod.yml target to runner-cli - Add 3 new tests for round-trip and detectFormat PR: #562
This commit is contained in:
@@ -8,10 +8,11 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || null;
|
||||
const BUILD_TIME_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || null;
|
||||
const CLOUD_ACTION_TIMEOUT_MS = 15000;
|
||||
|
||||
export default function APIPageClient({ machineId }) {
|
||||
const [resolvedMachineId, setResolvedMachineId] = useState(machineId || "");
|
||||
const t = useTranslations("endpoint");
|
||||
const tc = useTranslations("common");
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -29,7 +30,8 @@ export default function APIPageClient({ machineId }) {
|
||||
const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | "done" | ""
|
||||
const [modalSuccess, setModalSuccess] = useState(false); // show success state in modal before closing
|
||||
const [selectedProvider, setSelectedProvider] = useState(null); // for provider models popup
|
||||
const [cloudBaseUrl, setCloudBaseUrl] = useState(CLOUD_URL); // dynamic cloud URL from API response
|
||||
const [cloudBaseUrl, setCloudBaseUrl] = useState(BUILD_TIME_CLOUD_URL); // dynamic cloud URL from API response
|
||||
const [cloudConfigured, setCloudConfigured] = useState(Boolean(BUILD_TIME_CLOUD_URL));
|
||||
const [viewTab, setViewTab] = useState("api");
|
||||
const [mcpStatus, setMcpStatus] = useState<any>(null);
|
||||
const [a2aStatus, setA2aStatus] = useState<any>(null);
|
||||
@@ -136,6 +138,15 @@ export default function APIPageClient({ machineId }) {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCloudEnabled(data.cloudEnabled || false);
|
||||
if (typeof data.cloudConfigured === "boolean") {
|
||||
setCloudConfigured(data.cloudConfigured);
|
||||
}
|
||||
if (data.cloudUrl) {
|
||||
setCloudBaseUrl(data.cloudUrl);
|
||||
}
|
||||
if (data.machineId) {
|
||||
setResolvedMachineId(data.machineId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error loading cloud settings:", error);
|
||||
@@ -144,6 +155,13 @@ export default function APIPageClient({ machineId }) {
|
||||
|
||||
const handleCloudToggle = (checked) => {
|
||||
if (checked) {
|
||||
if (!cloudConfigured) {
|
||||
setCloudStatus({
|
||||
type: "warning",
|
||||
message: "Cloud sync is not configured on this instance.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setShowCloudModal(true);
|
||||
} else {
|
||||
setShowDisableModal(true);
|
||||
@@ -258,7 +276,12 @@ export default function APIPageClient({ machineId }) {
|
||||
};
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState("/v1");
|
||||
const cloudEndpointNew = cloudBaseUrl ? `${cloudBaseUrl}/v1` : null;
|
||||
const normalizedCloudBaseUrl = cloudBaseUrl
|
||||
? resolvedMachineId && !cloudBaseUrl.endsWith(`/${resolvedMachineId}`)
|
||||
? `${cloudBaseUrl}/${resolvedMachineId}`
|
||||
: cloudBaseUrl
|
||||
: null;
|
||||
const cloudEndpointNew = normalizedCloudBaseUrl ? `${normalizedCloudBaseUrl}/v1` : null;
|
||||
|
||||
// Hydration fix: Only access window on client side
|
||||
useEffect(() => {
|
||||
@@ -290,12 +313,23 @@ export default function APIPageClient({ machineId }) {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{cloudEnabled ? t("usingCloudProxy") : t("usingLocalServer")}
|
||||
</p>
|
||||
{machineId && (
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{t("machineId", { id: machineId.slice(0, 8) })}
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={cloudEnabled ? "primary" : "secondary"}
|
||||
icon={cloudEnabled ? "cloud_done" : "dns"}
|
||||
onClick={() => handleCloudToggle(!cloudEnabled)}
|
||||
disabled={cloudSyncing || (!cloudEnabled && !cloudConfigured)}
|
||||
className={
|
||||
cloudEnabled ? "" : "border-border/70! text-text-muted! hover:text-text!"
|
||||
}
|
||||
>
|
||||
{cloudEnabled ? t("usingCloudProxy") : t("usingLocalServer")}
|
||||
</Button>
|
||||
</div>
|
||||
{resolvedMachineId && (
|
||||
<p className="text-xs text-text-muted mt-2">
|
||||
{t("machineId", { id: resolvedMachineId.slice(0, 8) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -311,7 +345,7 @@ export default function APIPageClient({ machineId }) {
|
||||
>
|
||||
{t("disableCloud")}
|
||||
</Button>
|
||||
) : (
|
||||
) : cloudConfigured ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="cloud_upload"
|
||||
@@ -321,6 +355,10 @@ export default function APIPageClient({ machineId }) {
|
||||
>
|
||||
{t("enableCloud")}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-surface text-text-muted border border-border/70">
|
||||
Cloud not configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,16 +392,17 @@ export default function APIPageClient({ machineId }) {
|
||||
)}
|
||||
|
||||
{/* Endpoint URL */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2 mb-3">
|
||||
<Input
|
||||
value={currentEndpoint}
|
||||
readOnly
|
||||
className={`flex-1 font-mono text-sm ${cloudEnabled ? "animate-border-glow" : ""}`}
|
||||
className={`flex-1 min-w-0 font-mono text-sm ${cloudEnabled ? "animate-border-glow" : ""}`}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "endpoint_url" ? "check" : "content_copy"}
|
||||
onClick={() => copy(currentEndpoint, "endpoint_url")}
|
||||
className="shrink-0 self-start sm:self-auto"
|
||||
>
|
||||
{copied === "endpoint_url" ? tc("copied") : tc("copy")}
|
||||
</Button>
|
||||
|
||||
@@ -55,6 +55,21 @@ const STATIC_MODEL_PROVIDERS: Record<string, () => Array<{ id: string; name: str
|
||||
{ id: "nanobanana-flash", name: "NanoBanana Flash (Gemini 2.5 Flash)" },
|
||||
{ id: "nanobanana-pro", name: "NanoBanana Pro (Gemini 3 Pro)" },
|
||||
],
|
||||
antigravity: () => [
|
||||
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
|
||||
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" },
|
||||
],
|
||||
claude: () => [
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5 (2025-11-01)" },
|
||||
{ id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5 (2025-09-29)" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5 (2025-10-01)" },
|
||||
],
|
||||
perplexity: () => [
|
||||
{ id: "sonar", name: "Sonar (Fast Search)" },
|
||||
{ id: "sonar-pro", name: "Sonar Pro (Advanced Search)" },
|
||||
@@ -419,6 +434,14 @@ export async function GET(request, { params }) {
|
||||
});
|
||||
}
|
||||
|
||||
if (provider === "claude") {
|
||||
return NextResponse.json({
|
||||
provider,
|
||||
connectionId,
|
||||
models: STATIC_MODEL_PROVIDERS.claude(),
|
||||
});
|
||||
}
|
||||
|
||||
if (isAnthropicCompatibleProvider(provider)) {
|
||||
let baseUrl = getProviderBaseUrl(connection.providerSpecificData);
|
||||
if (!baseUrl) {
|
||||
@@ -434,13 +457,14 @@ export async function GET(request, { params }) {
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/models`;
|
||||
const token = accessToken || apiKey;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
...(apiKey ? { "x-api-key": apiKey } : {}),
|
||||
"anthropic-version": "2023-06-01",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { setCliCompatProviders } from "../../../../open-sse/config/cliFingerprints";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -20,6 +21,8 @@ export async function GET() {
|
||||
|
||||
const enableRequestLogs = process.env.ENABLE_REQUEST_LOGS === "true";
|
||||
const runtimePorts = getRuntimePorts();
|
||||
const cloudUrl = process.env.CLOUD_URL || process.env.NEXT_PUBLIC_CLOUD_URL || null;
|
||||
const machineId = await getConsistentMachineId();
|
||||
|
||||
return NextResponse.json({
|
||||
...safeSettings,
|
||||
@@ -28,6 +31,9 @@ export async function GET() {
|
||||
runtimePorts,
|
||||
apiPort: runtimePorts.apiPort,
|
||||
dashboardPort: runtimePorts.dashboardPort,
|
||||
cloudConfigured: Boolean(cloudUrl),
|
||||
cloudUrl,
|
||||
machineId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error getting settings:", error);
|
||||
|
||||
@@ -475,24 +475,39 @@ export default function OAuthModal({
|
||||
clearInterval(popupClosedInterval);
|
||||
clearTimeout(safetyTimeout);
|
||||
};
|
||||
|
||||
}, [step, isDeviceCode]);
|
||||
|
||||
// Handle manual URL input
|
||||
const handleManualSubmit = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const url = new URL(callbackUrl);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const errorParam = url.searchParams.get("error");
|
||||
const input = callbackUrl.trim();
|
||||
let code = null;
|
||||
let state = authData?.state || null;
|
||||
let errorParam = null;
|
||||
let errorDescription = null;
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
code = url.searchParams.get("code");
|
||||
state = url.searchParams.get("state") || url.hash.replace(/^#/, "") || state;
|
||||
errorParam = url.searchParams.get("error");
|
||||
errorDescription = url.searchParams.get("error_description");
|
||||
} catch {
|
||||
// Claude Code remote auth may provide a raw "Authentication Code" like code#state.
|
||||
const [rawCode, rawState] = input.split("#", 2);
|
||||
code = rawCode || null;
|
||||
state = rawState || state;
|
||||
}
|
||||
|
||||
if (errorParam) {
|
||||
throw new Error(url.searchParams.get("error_description") || errorParam);
|
||||
throw new Error(errorDescription || errorParam);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error("No authorization code found in URL");
|
||||
throw new Error(
|
||||
"No authorization code found. Paste the callback URL or the Authentication Code."
|
||||
);
|
||||
}
|
||||
|
||||
await exchangeTokens(code, state);
|
||||
@@ -626,14 +641,19 @@ export default function OAuthModal({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Step 2: Paste the callback URL here</p>
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Step 2: Paste the callback URL or auth code here
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
After authorization, copy the full URL from your browser.
|
||||
After authorization, paste the full callback URL. For Claude Code, you can also
|
||||
paste the Authentication Code directly, for example <code>code#state</code>.
|
||||
</p>
|
||||
<Input
|
||||
value={callbackUrl}
|
||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||
placeholder={placeholderUrl}
|
||||
placeholder={
|
||||
provider === "claude" ? "code#state or /callback?code=..." : placeholderUrl
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from "../services/auth";
|
||||
import { getModelInfo, getCombo } from "../services/model";
|
||||
import { parseModel } from "@omniroute/open-sse/services/model.ts";
|
||||
import { detectFormat, getTargetFormat } from "@omniroute/open-sse/services/provider.ts";
|
||||
import {
|
||||
detectFormatFromEndpoint,
|
||||
getTargetFormat,
|
||||
} from "@omniroute/open-sse/services/provider.ts";
|
||||
import { handleChatCore } from "@omniroute/open-sse/handlers/chatCore.ts";
|
||||
import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { handleComboChat } from "@omniroute/open-sse/services/combo.ts";
|
||||
@@ -321,7 +324,7 @@ async function handleSingleModelChat(
|
||||
runtimeOptions: { emergencyFallbackTried?: boolean; sessionId?: string | null } = {}
|
||||
) {
|
||||
// 1. Resolve model → provider/model
|
||||
const resolved = await resolveModelOrError(modelStr, body);
|
||||
const resolved = await resolveModelOrError(modelStr, body, clientRawRequest?.endpoint);
|
||||
if (resolved.error) return resolved.error;
|
||||
|
||||
const { provider, model, sourceFormat, targetFormat, extendedContext } = resolved;
|
||||
@@ -502,7 +505,7 @@ async function handleSingleModelChat(
|
||||
/**
|
||||
* Resolve model string to provider/model info, or return an error response.
|
||||
*/
|
||||
async function resolveModelOrError(modelStr: string, body: any) {
|
||||
async function resolveModelOrError(modelStr: string, body: any, endpointPath: string = "") {
|
||||
const modelInfo = await getModelInfo(modelStr);
|
||||
if (!modelInfo.provider) {
|
||||
if ((modelInfo as any).errorType === "ambiguous_model") {
|
||||
@@ -521,7 +524,7 @@ async function resolveModelOrError(modelStr: string, body: any) {
|
||||
}
|
||||
|
||||
const { provider, model, extendedContext } = modelInfo;
|
||||
const sourceFormat = detectFormat(body);
|
||||
const sourceFormat = detectFormatFromEndpoint(body, endpointPath);
|
||||
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
|
||||
// If the custom model specifies apiFormat="responses", override targetFormat
|
||||
|
||||
Reference in New Issue
Block a user