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:
diegosouzapw
2026-03-23 19:53:02 -03:00
parent 92e0f242c7
commit 18258b9b0d
13 changed files with 548 additions and 90 deletions

View File

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

View File

@@ -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}` } : {}),
},
});

View File

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

View File

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

View File

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