From c208f244ee884c9ea12823045da5908f239110ab Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 14 Jan 2026 15:42:38 +0700 Subject: [PATCH] Enhance chat handling. --- open-sse/handlers/chatCore.js | 15 +--- open-sse/utils/bypassHandler.js | 83 +++++++++---------- .../cli-tools/components/ClaudeToolCard.js | 50 +++++------ .../cli-tools/components/CodexToolCard.js | 77 +++++++---------- src/shared/components/ManualConfigModal.js | 46 ++++++++++ src/shared/components/index.js | 1 + src/sse/handlers/chat.js | 10 ++- 7 files changed, 145 insertions(+), 137 deletions(-) create mode 100644 src/shared/components/ManualConfigModal.js diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 6231bb9c..ee9a2fba 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -63,13 +63,13 @@ function extractUsageFromResponse(responseBody, provider) { * @param {function} options.onDisconnect - Callback when client disconnects * @param {string} options.connectionId - Connection ID for usage tracking */ -export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId }) { +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent }) { const { provider, model } = modelInfo; const sourceFormat = detectFormat(body); // Check for bypass patterns (warmup, skip) - return fake response - const bypassResponse = handleBypassRequest(body, model); + const bypassResponse = handleBypassRequest(body, model, userAgent); if (bypassResponse) { return bypassResponse; } @@ -173,17 +173,6 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred return createErrorResult(502, errMsg); } - // Log headers (mask sensitive values) - const safeHeaders = {}; - for (const [key, value] of Object.entries(providerHeaders || {})) { - if (key.toLowerCase().includes("auth") || key.toLowerCase().includes("key") || key.toLowerCase().includes("token")) { - safeHeaders[key] = value ? `${value.slice(0, 10)}...` : ""; - } else { - safeHeaders[key] = value; - } - } - log?.debug?.("HEADERS", JSON.stringify(safeHeaders)); - // Handle 401/403 - try token refresh using executor if (providerResponse.status === 401 || providerResponse.status === 403) { const newCredentials = await refreshWithRetry( diff --git a/open-sse/utils/bypassHandler.js b/open-sse/utils/bypassHandler.js index eb682590..a4e21f38 100644 --- a/open-sse/utils/bypassHandler.js +++ b/open-sse/utils/bypassHandler.js @@ -5,19 +5,14 @@ import { SKIP_PATTERNS } from "../config/constants.js"; import { formatSSE } from "./stream.js"; /** - * Check for bypass patterns (warmup, skip) - return fake response without calling provider - * Supports both streaming and non-streaming responses - * Returns response in the correct sourceFormat using translator - * - * @param {object} body - Request body - * @param {string} model - Model name - * @returns {object|null} { success: true, response: Response } or null if not bypass + * Check for bypass patterns - return fake response without calling provider + * Only works for Claude CLI requests */ -export function handleBypassRequest(body, model) { - const messages = body.messages; - if (!messages?.length) return null; +export function handleBypassRequest(body, model, userAgent = "") { + if (!userAgent.includes("claude-cli")) return null; + if (!body.messages?.length) return null; - // Helper to extract text from content + const messages = body.messages; const getText = (content) => { if (typeof content === "string") return content; if (Array.isArray(content)) { @@ -28,43 +23,45 @@ export function handleBypassRequest(body, model) { let shouldBypass = false; - // Check warmup: first message "Warmup" - const firstText = getText(messages[0]?.content); - if (firstText === "Warmup") { + // Pattern 1: Title extraction (assistant message = "{") + const lastMsg = messages[messages.length - 1]; + if (lastMsg?.role === "assistant" && lastMsg.content?.[0]?.text === "{") { shouldBypass = true; } - // Check count pattern: [{"role":"user","content":"count"}] - // if (!shouldBypass && - // messages.length === 1 && - // messages[0]?.role === "user" && - // firstText === "count") { - // shouldBypass = true; - // } + // Pattern 2: Warmup + if (!shouldBypass) { + const firstText = getText(messages[0]?.content); + if (firstText === "Warmup") { + shouldBypass = true; + } + } - // Check skip patterns - only check user messages, not system prompt + // Pattern 3: Count + if (!shouldBypass && messages.length === 1 && messages[0]?.role === "user") { + const firstText = getText(messages[0]?.content); + if (firstText === "count") { + shouldBypass = true; + } + } + + // Pattern 4: Skip patterns if (!shouldBypass && SKIP_PATTERNS?.length) { - // Only check user messages, skip system/assistant messages to avoid matching system prompts const userMessages = messages.filter(m => m.role === "user"); const userText = userMessages.map(m => getText(m.content)).join(" "); - const matchedPattern = SKIP_PATTERNS.find(p => userText.includes(p)); - if (matchedPattern) { + if (SKIP_PATTERNS.some(p => userText.includes(p))) { shouldBypass = true; } } if (!shouldBypass) return null; - // Detect source format and stream mode const sourceFormat = detectFormat(body); const stream = body.stream !== false; - // Create bypass response using translator - if (stream) { - return createStreamingResponse(sourceFormat, model); - } else { - return createNonStreamingResponse(sourceFormat, model); - } + return stream + ? createStreamingResponse(sourceFormat, model) + : createNonStreamingResponse(sourceFormat, model); } /** @@ -102,7 +99,7 @@ function createOpenAIResponse(model) { */ function createNonStreamingResponse(sourceFormat, model) { const openaiResponse = createOpenAIResponse(model); - + // If sourceFormat is OpenAI, return directly if (sourceFormat === FORMATS.OPENAI) { return { @@ -119,26 +116,26 @@ function createNonStreamingResponse(sourceFormat, model) { // Use translator to convert: simulate streaming then collect all chunks const state = initState(sourceFormat); state.model = model; - + const openaiChunks = createOpenAIStreamingChunks(openaiResponse); const allTranslated = []; - + for (const chunk of openaiChunks) { const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state); if (translated?.length > 0) { allTranslated.push(...translated); } } - + // Flush remaining const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state); if (flushed?.length > 0) { allTranslated.push(...flushed); } - + // For non-streaming, merge all chunks into final response const finalResponse = mergeChunksToResponse(allTranslated, sourceFormat); - + return { success: true, response: new Response(JSON.stringify(finalResponse), { @@ -164,7 +161,7 @@ function createStreamingResponse(sourceFormat, model) { // Translate each chunk to sourceFormat using translator const translatedChunks = []; - + for (const chunk of openaiChunks) { const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state); if (translated?.length > 0) { @@ -206,11 +203,11 @@ function mergeChunksToResponse(chunks, sourceFormat) { if (!chunks || chunks.length === 0) { return createOpenAIResponse("unknown"); } - + // For most formats, the last chunk before done contains the complete response // Find the most complete chunk (usually the last one with content) let finalChunk = chunks[chunks.length - 1]; - + // For Claude format, find the message_stop or final message if (sourceFormat === FORMATS.CLAUDE) { const messageStop = chunks.find(c => c.type === "message_stop"); @@ -219,7 +216,7 @@ function mergeChunksToResponse(chunks, sourceFormat) { const contentDelta = chunks.find(c => c.type === "content_block_delta"); const messageDelta = chunks.find(c => c.type === "message_delta"); const messageStart = chunks.find(c => c.type === "message_start"); - + if (messageStart?.message) { finalChunk = messageStart.message; // Merge usage if available @@ -229,7 +226,7 @@ function mergeChunksToResponse(chunks, sourceFormat) { } } } - + return finalChunk; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index b9152cbe..a6f8018b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Card, Button, ModelSelectModal } from "@/shared/components"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -27,8 +27,8 @@ export default function ClaudeToolCard({ const [modalOpen, setModalOpen] = useState(false); const [currentEditingAlias, setCurrentEditingAlias] = useState(null); const [selectedApiKey, setSelectedApiKey] = useState(""); - const [copiedConfig, setCopiedConfig] = useState(false); const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); const getConfigStatus = () => { if (!claudeStatus?.installed) return null; @@ -163,7 +163,7 @@ export default function ClaudeToolCard({ }; // Generate settings.json content for manual copy - const getSettingsContent = () => { + const getManualConfigs = () => { const keyToUse = (selectedApiKey && selectedApiKey.trim()) ? selectedApiKey : (!cloudEnabled ? "sk_9router" : ""); @@ -172,17 +172,13 @@ export default function ClaudeToolCard({ const targetModel = modelMappings[model.alias]; if (targetModel && model.envKey) env[model.envKey] = targetModel; }); - return JSON.stringify({ env }, null, 2); - }; - - const copyToClipboard = async (text) => { - try { - await navigator.clipboard.writeText(text); - setCopiedConfig(true); - setTimeout(() => setCopiedConfig(false), 2000); - } catch (err) { - console.log("Failed to copy:", err); - } + + return [ + { + filename: "~/.claude/settings.json", + content: JSON.stringify({ env }, null, 2), + }, + ]; }; return ( @@ -289,31 +285,23 @@ export default function ClaudeToolCard({ - - - {/* Manual Config Section */} -
-

Or copy config manually:

-
-
- ~/.claude/settings.json - -
-
{getSettingsContent()}
-
-
)} )} setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} /> + + setShowManualConfigModal(false)} + title="Claude CLI - Manual Configuration" + configs={getManualConfigs()} + /> ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index 9764c9ec..faaa396f 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Card, Button, ModelSelectModal } from "@/shared/components"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled }) { @@ -13,9 +13,9 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api const [showInstallGuide, setShowInstallGuide] = useState(false); const [selectedApiKey, setSelectedApiKey] = useState(""); const [selectedModel, setSelectedModel] = useState(""); - const [copiedConfig, setCopiedConfig] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); useEffect(() => { if (apiKeys?.length > 0 && !selectedApiKey) { @@ -123,7 +123,12 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api setModalOpen(false); }; - const configContent = `# 9Router Configuration for Codex CLI + const getManualConfigs = () => { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : ""); + + const configContent = `# 9Router Configuration for Codex CLI model = "${selectedModel}" model_provider = "9router" @@ -133,22 +138,20 @@ base_url = "${baseUrl}/v1" wire_api = "responses" `; - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - - const authContent = JSON.stringify({ - OPENAI_API_KEY: keyToUse - }, null, 2); + const authContent = JSON.stringify({ + OPENAI_API_KEY: keyToUse + }, null, 2); - const copyToClipboard = async (text) => { - try { - await navigator.clipboard.writeText(text); - setCopiedConfig(true); - setTimeout(() => setCopiedConfig(false), 2000); - } catch (err) { - console.log("Failed to copy:", err); - } + return [ + { + filename: "~/.codex/config.toml", + content: configContent, + }, + { + filename: "~/.codex/auth.json", + content: authContent, + }, + ]; }; return ( @@ -257,39 +260,12 @@ wire_api = "responses" - )} - - {/* Manual Config Section */} -
-

Or copy config manually:

- -
-
- ~/.codex/config.toml - -
-
{configContent}
-
- -
-
- ~/.codex/auth.json - -
-
{authContent}
-
-
)} @@ -302,6 +278,13 @@ wire_api = "responses" modelAliases={modelAliases} title="Select Model for Codex" /> + + setShowManualConfigModal(false)} + title="Codex CLI - Manual Configuration" + configs={getManualConfigs()} + /> ); } diff --git a/src/shared/components/ManualConfigModal.js b/src/shared/components/ManualConfigModal.js new file mode 100644 index 00000000..c52618f1 --- /dev/null +++ b/src/shared/components/ManualConfigModal.js @@ -0,0 +1,46 @@ +"use client"; + +import { useState } from "react"; +import Modal from "./Modal"; +import Button from "./Button"; + +export default function ManualConfigModal({ isOpen, onClose, title = "Manual Configuration", configs = [] }) { + const [copiedIndex, setCopiedIndex] = useState(null); + + const copyToClipboard = async (text, index) => { + try { + await navigator.clipboard.writeText(text); + setCopiedIndex(index); + setTimeout(() => setCopiedIndex(null), 2000); + } catch (err) { + console.log("Failed to copy:", err); + } + }; + + return ( + +
+ {configs.map((config, index) => ( +
+
+ {config.filename} + +
+
+              {config.content}
+            
+
+ ))} +
+
+ ); +} diff --git a/src/shared/components/index.js b/src/shared/components/index.js index 3e30a83b..cf983335 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -15,6 +15,7 @@ export { default as Header } from "./Header"; export { default as Footer } from "./Footer"; export { default as OAuthModal } from "./OAuthModal"; export { default as ModelSelectModal } from "./ModelSelectModal"; +export { default as ManualConfigModal } from "./ManualConfigModal"; export { default as UsageStats } from "./UsageStats"; export { default as RequestLogger } from "./RequestLogger"; diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 8b1cbc40..b9674147 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -61,19 +61,19 @@ export async function handleChat(request, clientRawRequest = null) { return handleComboChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest), + handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request), log }); } // Single model request - return handleSingleModelChat(body, modelStr, clientRawRequest); + return handleSingleModelChat(body, modelStr, clientRawRequest, request); } /** * Handle single model chat request */ -async function handleSingleModelChat(body, modelStr, clientRawRequest = null) { +async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null) { const modelInfo = await getModelInfo(modelStr); if (!modelInfo.provider) { log.warn("CHAT", "Invalid model format", { model: modelStr }); @@ -89,6 +89,9 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null) { log.info("ROUTING", `Provider: ${provider}, Model: ${model}`); } + // Extract userAgent from request + const userAgent = request?.headers?.get("user-agent") || ""; + // Try with available accounts (fallback on errors) let excludeConnectionId = null; let lastError = null; @@ -121,6 +124,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null) { log, clientRawRequest, connectionId: credentials.connectionId, + userAgent, onCredentialsRefreshed: async (newCreds) => { await updateProviderCredentials(credentials.connectionId, { accessToken: newCreds.accessToken,