From 18258b9b0d416e101a28fa44a3f0a28eb6277b87 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 23 Mar 2026 19:53:02 -0300 Subject: [PATCH] =?UTF-8?q?fix:=20merge=20PR=20#562=20=E2=80=94=20MCP=20se?= =?UTF-8?q?ssion=20management,=20Claude=20passthrough,=20OAuth=20modal,=20?= =?UTF-8?q?detectFormat=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .agents/workflows/review-discussions.md | 118 +++++++++ docker-compose.prod.yml | 2 +- open-sse/handlers/chatCore.ts | 45 +++- open-sse/index.ts | 1 + open-sse/mcp-server/httpTransport.ts | 234 ++++++++++++++---- open-sse/services/provider.ts | 21 ++ open-sse/translator/index.ts | 2 +- .../dashboard/endpoint/EndpointPageClient.tsx | 63 ++++- src/app/api/providers/[id]/models/route.ts | 28 ++- src/app/api/settings/route.ts | 6 + src/shared/components/OAuthModal.tsx | 40 ++- src/sse/handlers/chat.ts | 11 +- tests/unit/plan3-p0.test.mjs | 67 ++++- 13 files changed, 548 insertions(+), 90 deletions(-) create mode 100644 .agents/workflows/review-discussions.md diff --git a/.agents/workflows/review-discussions.md b/.agents/workflows/review-discussions.md new file mode 100644 index 00000000..e9f6a0cd --- /dev/null +++ b/.agents/workflows/review-discussions.md @@ -0,0 +1,118 @@ +--- +description: Read all open GitHub Discussions, summarize them, respond to pending ones, and create issues from actionable feature requests +--- + +# /review-discussions — GitHub Discussions Review & Response Workflow + +## Overview + +This workflow reads all open GitHub Discussions, generates a categorized summary, identifies which ones need a response, drafts and posts replies, and optionally creates issues from actionable feature requests. It follows the same flow used for Issues but adapted for the Discussions forum. + +// turbo-all + +## Steps + +### 1. Identify the GitHub Repository + +- Run: `git -C remote get-url origin` to extract the owner/repo +- Parse the owner and repo name from the URL + +### 2. Fetch All Open Discussions + +- Use `read_url_content` to fetch `https://github.com///discussions` +- Parse the discussion list to get all discussion titles, IDs, authors, categories, and dates +- For each discussion, fetch the individual page to read the full content and all comments/replies + +### 3. Summarize All Discussions + +For each discussion, extract: + +- **Title** and **#Number** +- **Author** (GitHub username) +- **Category** (Announcements, General, Ideas, Q&A, Show and tell) +- **Date** created +- **Summary** of the original post (1-2 sentences) +- **Comments count** and key participants +- **Your previous response** (if any) +- **Pending action** — whether a response or follow-up is needed + +### 4. Present Summary Report to User + +Present the full summary to the user organized by category, using a table: + +| # | Category | Title | Author | Date | Status | +| --- | -------- | ----- | ------ | ------ | ----------------- | +| #N | Ideas | Title | @user | Mar 23 | ⚠️ Needs response | +| #N | Q&A | Title | @user | Mar 9 | ✅ Answered | +| #N | General | Title | @user | Mar 19 | ⚠️ Needs response | + +Highlight: + +- **⚠️ Needs response** — No reply from maintainer, or a follow-up comment was left unanswered +- **✅ Answered** — Maintainer already responded +- **🐛 Bug reported** — A bug was mentioned that needs tracking +- **💡 Actionable** — Contains a concrete feature request that could become an issue + +### 5. Draft & Post Responses + +For each discussion that needs a response, draft a reply following these guidelines: + +#### Response Style + +- **Friendly and professional** — Start with "Hey @username!" +- **Acknowledge the contribution** — Thank the user for their input +- **Be specific** — Reference existing features, settings, or dashboard pages if the feature already exists +- **Provide workarounds** — If the request isn't implemented yet, suggest current alternatives +- **Commit to action** — If the request is valid, state that you'll open an issue or add it to the roadmap +- **Keep it concise** — 3-5 paragraphs max + +#### Posting via Browser + +- Use `browser_subagent` to navigate to each discussion and post the comment +- **IMPORTANT**: When typing text in GitHub comment boxes via the browser, use only plain ASCII characters: + - Use regular hyphens `-` instead of em-dashes + - Use `->` instead of arrow symbols + - Do NOT use emoji Unicode characters (the browser keyboard may fail on them) + - Use `**bold**` and `\`code\`` markdown formatting +- Click the green "Comment" button (or "Reply" for threaded replies) after typing +- Verify the comment was posted by checking the page shows the new comment + +### 6. Create Issues from Actionable Feature Requests + +For discussions that contain concrete, actionable feature requests: + +1. Ask the user which ones should become issues +2. For each approved request, create a GitHub issue via `browser_subagent`: + - Navigate to `https://github.com///issues/new` + - **Title**: ` - ` + - **Body** should include: + - `## Feature Request` header + - `**Source:** Discussion #N by @author` + - `## Problem` — What limitation the user hit + - `## Proposed Solution` — How it could work + - `### Implementation Ideas` — Technical approach + - `### Current Workarounds` — What users can do today + - `## Additional Context` — Links to related issues/discussions + - Add `enhancement` label + - Click "Submit new issue" / "Create" +3. After creation, go back to the original discussion and post a comment linking to the new issue: + - "I've opened Issue #N to track this feature request. Follow along there for updates!" + +### 7. Final Report + +Present a final summary to the user: + +| Discussion | Action Taken | +| ---------- | ---------------------------------- | +| #N — Title | Responded with workarounds | +| #N — Title | Responded + created Issue #N | +| #N — Title | Already answered, no action needed | +| #N — Title | Responded to follow-up comment | + +## Notes + +- This workflow is **interactive** — always present the summary and wait for user approval before posting responses or creating issues +- If the user says "pode responder" (or similar approval), proceed with posting all drafted responses +- For discussions in non-English languages, respond in the same language as the original post +- Always reference specific dashboard paths, config options, or code files when explaining existing features +- When a discussion reveals a bug, note it separately from feature requests diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a58a8a50..5674b10c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -16,7 +16,7 @@ services: container_name: omniroute-prod build: context: . - target: runner-base + target: runner-cli image: omniroute:prod restart: unless-stopped env_file: .env diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d7bcfde9..40dbe6da 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1,5 +1,5 @@ import { getCorsOrigin } from "../utils/cors.ts"; -import { detectFormat, getTargetFormat } from "../services/provider.ts"; +import { detectFormatFromEndpoint, getTargetFormat } from "../services/provider.ts"; import { translateRequest, needsTranslation } from "../translator/index.ts"; import { FORMATS } from "../translator/formats.ts"; import { @@ -216,8 +216,8 @@ export async function handleChatCore({ credentials.connectionId = connectionId; } - const sourceFormat = detectFormat(body); const endpointPath = String(clientRawRequest?.endpoint || ""); + const sourceFormat = detectFormatFromEndpoint(body, endpointPath); const isResponsesEndpoint = /(?:^|\/)responses(?:\/.*)?$/i.test(endpointPath); const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({ provider, @@ -332,11 +332,42 @@ export async function handleChatCore({ translatedBody = { ...body, _nativeCodexPassthrough: true }; log?.debug?.("FORMAT", "native codex passthrough enabled"); } else if (isClaudePassthrough) { - // Claude-to-Claude passthrough: forward body completely untouched. - // No translation, no field stripping, no thinking normalization. - // We are just a gateway -- do not interfere with the request in the slightest. - translatedBody = { ...body }; - log?.debug?.("FORMAT", "claude->claude passthrough -- forwarding untouched"); + // Claude OAuth expects the same Claude Code prompt + structural normalization + // as the OpenAI-compatible chat path. Round-trip through OpenAI to reuse the + // working Claude translator instead of forwarding raw Messages payloads. + const normalizeToolCallId = getModelNormalizeToolCallId( + provider || "", + model || "", + sourceFormat + ); + const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole( + provider || "", + model || "", + sourceFormat + ); + translatedBody = translateRequest( + FORMATS.CLAUDE, + FORMATS.OPENAI, + model, + { ...body }, + stream, + credentials, + provider, + reqLogger, + { normalizeToolCallId, preserveDeveloperRole } + ); + translatedBody = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + model, + translatedBody, + stream, + credentials, + provider, + reqLogger, + { normalizeToolCallId, preserveDeveloperRole } + ); + log?.debug?.("FORMAT", "claude->openai->claude normalized passthrough"); } else { translatedBody = { ...body }; diff --git a/open-sse/index.ts b/open-sse/index.ts index 0292fd17..bc7e4955 100644 --- a/open-sse/index.ts +++ b/open-sse/index.ts @@ -36,6 +36,7 @@ export { // Services export { detectFormat, + detectFormatFromEndpoint, getProviderConfig, buildProviderUrl, buildProviderHeaders, diff --git a/open-sse/mcp-server/httpTransport.ts b/open-sse/mcp-server/httpTransport.ts index cd467acd..d52f9586 100644 --- a/open-sse/mcp-server/httpTransport.ts +++ b/open-sse/mcp-server/httpTransport.ts @@ -1,5 +1,5 @@ /** - * MCP HTTP Transport Layer — Singleton server + SSE/Streamable HTTP handlers. + * MCP HTTP Transport Layer — session-aware handlers for SSE and Streamable HTTP. * * Runs the MCP server **inside** the Next.js process so it can be toggled * from the dashboard without requiring `omniroute --mcp`. @@ -14,58 +14,188 @@ import { createMcpServer } from "./server.ts"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -// ────── Singleton ────────────────────────────────────────── +let _sseServer: McpServer | null = null; +let _sseTransport: WebStandardStreamableHTTPServerTransport | null = null; +let _sseStartedAt: number | null = null; -let _server: McpServer | null = null; -let _transport: WebStandardStreamableHTTPServerTransport | null = null; -let _startedAt: number | null = null; -let _activeTransportMode: "sse" | "streamable-http" | null = null; +type StreamableSession = { + sessionId: string; + server: McpServer; + transport: WebStandardStreamableHTTPServerTransport; + startedAt: number; +}; -function ensureServer(mode: "sse" | "streamable-http"): { +const _streamableSessions = new Map(); + +function closeSseTransport(): void { + if (_sseTransport) { + try { + _sseTransport.close(); + } catch { + // ignore shutdown errors + } + } + _sseServer = null; + _sseTransport = null; + _sseStartedAt = null; +} + +function closeStreamableSession(sessionId: string): void { + const session = _streamableSessions.get(sessionId); + if (!session) { + return; + } + + try { + session.transport.close(); + } catch { + // ignore shutdown errors + } + _streamableSessions.delete(sessionId); +} + +function closeAllStreamableSessions(): void { + for (const sessionId of _streamableSessions.keys()) { + closeStreamableSession(sessionId); + } +} + +function ensureSseServer(): { server: McpServer; transport: WebStandardStreamableHTTPServerTransport; } { - if (_server && _transport && _activeTransportMode === mode) { - return { server: _server, transport: _transport }; + if (_sseServer && _sseTransport) { + return { server: _sseServer, transport: _sseTransport }; } - // Shutdown previous if switching modes - if (_transport) { - try { _transport.close(); } catch { /* ignore */ } - } + closeAllStreamableSessions(); - _server = createMcpServer(); - _transport = new WebStandardStreamableHTTPServerTransport({ + _sseServer = createMcpServer(); + _sseTransport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), }); - _activeTransportMode = mode; - _startedAt = Date.now(); + _sseStartedAt = Date.now(); - // Connect server to transport (fire-and-forget, will be ready by first request) - void _server.connect(_transport); + void _sseServer.connect(_sseTransport); - console.log(`[MCP] HTTP transport started (${mode})`); - return { server: _server, transport: _transport }; + console.log("[MCP] HTTP transport started (sse)"); + return { server: _sseServer, transport: _sseTransport }; } -// ────── Streamable HTTP Handler ──────────────────────────── +function createStreamableSession(): StreamableSession { + closeSseTransport(); + + const sessionId = randomUUID(); + const server = createMcpServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => sessionId, + }); + const session = { + sessionId, + server, + transport, + startedAt: Date.now(), + }; + + void server.connect(transport); + _streamableSessions.set(sessionId, session); + console.log(`[MCP] HTTP transport started (streamable-http:${sessionId})`); + return session; +} + +async function isInitializeRequest(request: Request): Promise { + if (request.method !== "POST") { + return false; + } + + try { + const body = (await request.clone().json()) as { method?: unknown }; + return body?.method === "initialize"; + } catch { + return false; + } +} + +function errorResponse(message: string, code: number, status = 400): Response { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + error: { code, message }, + id: null, + }), + { + status, + headers: { "Content-Type": "application/json" }, + } + ); +} + +function withSessionHeader(response: Response, sessionId: string): Response { + if (response.headers.get("mcp-session-id")) { + return response; + } + + const headers = new Headers(response.headers); + headers.set("mcp-session-id", sessionId); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +async function handleStreamableRequest(request: Request): Promise { + const sessionId = request.headers.get("mcp-session-id"); + + if (sessionId) { + const session = _streamableSessions.get(sessionId); + if (!session) { + return errorResponse("Bad Request: Unknown Mcp-Session-Id header", -32000); + } + + try { + const response = await session.transport.handleRequest(request); + if (request.method === "DELETE") { + closeStreamableSession(sessionId); + } + return withSessionHeader(response, sessionId); + } catch (err) { + console.error("[MCP] Streamable HTTP error:", err); + if (request.method === "DELETE") { + closeStreamableSession(sessionId); + } + return new Response(JSON.stringify({ error: "MCP transport error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + } + + if (!(await isInitializeRequest(request))) { + return errorResponse("Bad Request: Mcp-Session-Id header is required", -32000); + } + + const session = createStreamableSession(); + + try { + const response = await session.transport.handleRequest(request); + return withSessionHeader(response, session.sessionId); + } catch (err) { + closeStreamableSession(session.sessionId); + console.error("[MCP] Streamable HTTP error:", err); + return new Response(JSON.stringify({ error: "MCP transport error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +} /** * Handle Streamable HTTP requests (POST / GET / DELETE). * Used by the Next.js route at /api/mcp/stream. */ export async function handleMcpStreamableHTTP(request: Request): Promise { - const { transport } = ensureServer("streamable-http"); - - try { - return await transport.handleRequest(request); - } catch (err) { - console.error("[MCP] Streamable HTTP error:", err); - return new Response( - JSON.stringify({ error: "MCP transport error" }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); - } + return handleStreamableRequest(request); } /** @@ -74,47 +204,47 @@ export async function handleMcpStreamableHTTP(request: Request): Promise { - const { transport } = ensureServer("sse"); + const { transport } = ensureSseServer(); try { return await transport.handleRequest(request); } catch (err) { console.error("[MCP] SSE error:", err); - return new Response( - JSON.stringify({ error: "MCP SSE transport error" }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); + return new Response(JSON.stringify({ error: "MCP SSE transport error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); } } -// ────── Status & Lifecycle ───────────────────────────────── - export function getMcpHttpStatus(): { online: boolean; transport: string | null; startedAt: number | null; uptime: string | null; } { - const online = _transport !== null && _activeTransportMode !== null; + const streamableStartedAt = + _streamableSessions.size > 0 + ? Math.min(...Array.from(_streamableSessions.values(), (session) => session.startedAt)) + : null; + const startedAt = streamableStartedAt ?? _sseStartedAt; + const transport = _streamableSessions.size > 0 ? "streamable-http" : _sseTransport ? "sse" : null; + const online = transport !== null; + return { online, - transport: _activeTransportMode, - startedAt: _startedAt, - uptime: _startedAt ? `${Math.floor((Date.now() - _startedAt) / 1000)}s` : null, + transport, + startedAt, + uptime: startedAt ? `${Math.floor((Date.now() - startedAt) / 1000)}s` : null, }; } export function shutdownMcpHttp(): void { - if (_transport) { - try { _transport.close(); } catch { /* ignore */ } - } - _server = null; - _transport = null; - _activeTransportMode = null; - _startedAt = null; + closeSseTransport(); + closeAllStreamableSessions(); console.log("[MCP] HTTP transport shutdown"); } export function isMcpHttpActive(): boolean { - return _transport !== null; + return _sseTransport !== null || _streamableSessions.size > 0; } diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index 5815f7bd..e7e8dbe3 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -35,6 +35,27 @@ function buildAnthropicCompatibleUrl(baseUrl) { return `${normalized}/messages`; } +// Detect request format from endpoint first when the route is known. +// This avoids ambiguous bodies like OpenAI /chat/completions requests that also +// contain max_tokens or Claude model names. +export function detectFormatFromEndpoint(body, endpointPath = "") { + const path = String(endpointPath || ""); + + if (/(?:^|\/)responses(?:\/.*)?$/i.test(path)) { + return "openai-responses"; + } + + if (/(?:^|\/)messages(?:\/.*)?$/i.test(path)) { + return "claude"; + } + + if (/(?:^|\/)(?:chat\/completions|completions)(?:\/.*)?$/i.test(path)) { + return "openai"; + } + + return detectFormat(body); +} + // Detect request format from body structure export function detectFormat(body) { // OpenAI Responses API: diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 42aad003..25f70900 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -144,7 +144,7 @@ export function translateRequest( } // Final step: prepare request for Claude format endpoints - if (targetFormat === FORMATS.CLAUDE && sourceFormat !== FORMATS.CLAUDE) { + if (targetFormat === FORMATS.CLAUDE) { result = prepareClaudeRequest(result, provider); } diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index cc8cf8d3..2befac4f 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -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(null); const [a2aStatus, setA2aStatus] = useState(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 }) {

{t("title")}

-

- {cloudEnabled ? t("usingCloudProxy") : t("usingLocalServer")} -

- {machineId && ( -

- {t("machineId", { id: machineId.slice(0, 8) })} +

+ +
+ {resolvedMachineId && ( +

+ {t("machineId", { id: resolvedMachineId.slice(0, 8) })}

)}
@@ -311,7 +345,7 @@ export default function APIPageClient({ machineId }) { > {t("disableCloud")} - ) : ( + ) : cloudConfigured ? ( + ) : ( + + Cloud not configured + )}
@@ -354,16 +392,17 @@ export default function APIPageClient({ machineId }) { )} {/* Endpoint URL */} -
+
diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index b02cf44d..2da6e180 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -55,6 +55,21 @@ const STATIC_MODEL_PROVIDERS: Record 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}` } : {}), }, }); diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 1373d233..84725409 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -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); diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 534ad018..6a4c73c0 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -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({
-

Step 2: Paste the callback URL here

+

+ Step 2: Paste the callback URL or auth code here +

- 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#state.

setCallbackUrl(e.target.value)} - placeholder={placeholderUrl} + placeholder={ + provider === "claude" ? "code#state or /callback?code=..." : placeholderUrl + } className="font-mono text-xs" />
diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 4ffdf46b..6ee7a480 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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 diff --git a/tests/unit/plan3-p0.test.mjs b/tests/unit/plan3-p0.test.mjs index 7ddc04cc..6fa213b9 100644 --- a/tests/unit/plan3-p0.test.mjs +++ b/tests/unit/plan3-p0.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { FORMATS } from "../../open-sse/translator/formats.ts"; import { getModelInfoCore } from "../../open-sse/services/model.ts"; -import { detectFormat } from "../../open-sse/services/provider.ts"; +import { detectFormat, detectFormatFromEndpoint } from "../../open-sse/services/provider.ts"; import { shouldUseNativeCodexPassthrough } from "../../open-sse/handlers/chatCore.ts"; import { translateRequest } from "../../open-sse/translator/index.ts"; import { GithubExecutor } from "../../open-sse/executors/github.ts"; @@ -79,6 +79,45 @@ test("CodexExecutor forces stream=true for upstream compatibility", () => { assert.equal(transformed.stream, true); }); +test("Claude native messages can be round-tripped through OpenAI into Claude OAuth format", () => { + const normalizeOptions = { normalizeToolCallId: false, preserveDeveloperRole: undefined }; + const openaiBody = translateRequest( + FORMATS.CLAUDE, + FORMATS.OPENAI, + "claude-sonnet-4-6", + { + model: "claude-sonnet-4-6", + max_tokens: 32, + messages: [{ role: "user", content: "reply with OK only" }], + }, + false, + null, + "claude", + null, + normalizeOptions + ); + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "claude-sonnet-4-6", + openaiBody, + false, + null, + "claude", + null, + normalizeOptions + ); + + assert.deepEqual(translated.messages, [ + { + role: "user", + content: [{ type: "text", text: "reply with OK only" }], + }, + ]); + assert.ok(Array.isArray(translated.system)); + assert.equal(translated.system[0]?.text?.includes("You are Claude Code"), true); +}); + test("CodexExecutor maps fast service tier to priority", () => { const executor = new CodexExecutor(); const transformed = executor.transformRequest( @@ -320,6 +359,32 @@ test("detectFormat identifies OpenAI Responses by max_output_tokens without inpu assert.equal(format, FORMATS.OPENAI_RESPONSES); }); +test("detectFormatFromEndpoint forces OpenAI for /v1/chat/completions", () => { + const format = detectFormatFromEndpoint( + { + model: "cc/claude-opus-4-6", + messages: [{ role: "user", content: "hi" }], + max_tokens: 16, + stream: false, + }, + "/v1/chat/completions" + ); + assert.equal(format, FORMATS.OPENAI); +}); + +test("detectFormatFromEndpoint forces Claude for /v1/messages", () => { + const format = detectFormatFromEndpoint( + { + model: "claude-opus-4-6", + messages: [{ role: "user", content: "hi" }], + max_tokens: 16, + stream: false, + }, + "/v1/messages" + ); + assert.equal(format, FORMATS.CLAUDE); +}); + test("translateRequest normalizes openai-responses input string into list payload", () => { const translated = translateRequest( FORMATS.OPENAI_RESPONSES,