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:
@@ -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 <project_root> 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/<owner>/<repo>/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/<owner>/<repo>/issues/new`
|
||||
- **Title**: `<Feature Name> - <Short description>`
|
||||
- **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
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export {
|
||||
// Services
|
||||
export {
|
||||
detectFormat,
|
||||
detectFormatFromEndpoint,
|
||||
getProviderConfig,
|
||||
buildProviderUrl,
|
||||
buildProviderHeaders,
|
||||
|
||||
@@ -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<string, StreamableSession>();
|
||||
|
||||
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<boolean> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Respons
|
||||
* and POST for messages (the Streamable HTTP transport supports both patterns).
|
||||
*/
|
||||
export async function handleMcpSSE(request: Request): Promise<Response> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user