Enhance chat handling.

This commit is contained in:
decolua
2026-01-14 15:42:38 +07:00
parent c39eca6d4e
commit c208f244ee
7 changed files with 145 additions and 137 deletions

View File

@@ -63,13 +63,13 @@ function extractUsageFromResponse(responseBody, provider) {
* @param {function} options.onDisconnect - Callback when client disconnects * @param {function} options.onDisconnect - Callback when client disconnects
* @param {string} options.connectionId - Connection ID for usage tracking * @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 { provider, model } = modelInfo;
const sourceFormat = detectFormat(body); const sourceFormat = detectFormat(body);
// Check for bypass patterns (warmup, skip) - return fake response // Check for bypass patterns (warmup, skip) - return fake response
const bypassResponse = handleBypassRequest(body, model); const bypassResponse = handleBypassRequest(body, model, userAgent);
if (bypassResponse) { if (bypassResponse) {
return bypassResponse; return bypassResponse;
} }
@@ -173,17 +173,6 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
return createErrorResult(502, errMsg); 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 // Handle 401/403 - try token refresh using executor
if (providerResponse.status === 401 || providerResponse.status === 403) { if (providerResponse.status === 401 || providerResponse.status === 403) {
const newCredentials = await refreshWithRetry( const newCredentials = await refreshWithRetry(

View File

@@ -5,19 +5,14 @@ import { SKIP_PATTERNS } from "../config/constants.js";
import { formatSSE } from "./stream.js"; import { formatSSE } from "./stream.js";
/** /**
* Check for bypass patterns (warmup, skip) - return fake response without calling provider * Check for bypass patterns - return fake response without calling provider
* Supports both streaming and non-streaming responses * Only works for Claude CLI requests
* 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
*/ */
export function handleBypassRequest(body, model) { export function handleBypassRequest(body, model, userAgent = "") {
const messages = body.messages; if (!userAgent.includes("claude-cli")) return null;
if (!messages?.length) return null; if (!body.messages?.length) return null;
// Helper to extract text from content const messages = body.messages;
const getText = (content) => { const getText = (content) => {
if (typeof content === "string") return content; if (typeof content === "string") return content;
if (Array.isArray(content)) { if (Array.isArray(content)) {
@@ -28,43 +23,45 @@ export function handleBypassRequest(body, model) {
let shouldBypass = false; let shouldBypass = false;
// Check warmup: first message "Warmup" // Pattern 1: Title extraction (assistant message = "{")
const firstText = getText(messages[0]?.content); const lastMsg = messages[messages.length - 1];
if (firstText === "Warmup") { if (lastMsg?.role === "assistant" && lastMsg.content?.[0]?.text === "{") {
shouldBypass = true; shouldBypass = true;
} }
// Check count pattern: [{"role":"user","content":"count"}] // Pattern 2: Warmup
// if (!shouldBypass && if (!shouldBypass) {
// messages.length === 1 && const firstText = getText(messages[0]?.content);
// messages[0]?.role === "user" && if (firstText === "Warmup") {
// firstText === "count") { shouldBypass = true;
// 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) { 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 userMessages = messages.filter(m => m.role === "user");
const userText = userMessages.map(m => getText(m.content)).join(" "); const userText = userMessages.map(m => getText(m.content)).join(" ");
const matchedPattern = SKIP_PATTERNS.find(p => userText.includes(p)); if (SKIP_PATTERNS.some(p => userText.includes(p))) {
if (matchedPattern) {
shouldBypass = true; shouldBypass = true;
} }
} }
if (!shouldBypass) return null; if (!shouldBypass) return null;
// Detect source format and stream mode
const sourceFormat = detectFormat(body); const sourceFormat = detectFormat(body);
const stream = body.stream !== false; const stream = body.stream !== false;
// Create bypass response using translator return stream
if (stream) { ? createStreamingResponse(sourceFormat, model)
return createStreamingResponse(sourceFormat, model); : createNonStreamingResponse(sourceFormat, model);
} else {
return createNonStreamingResponse(sourceFormat, model);
}
} }
/** /**

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; 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"; import Image from "next/image";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -27,8 +27,8 @@ export default function ClaudeToolCard({
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [currentEditingAlias, setCurrentEditingAlias] = useState(null); const [currentEditingAlias, setCurrentEditingAlias] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState(""); const [selectedApiKey, setSelectedApiKey] = useState("");
const [copiedConfig, setCopiedConfig] = useState(false);
const [modelAliases, setModelAliases] = useState({}); const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const getConfigStatus = () => { const getConfigStatus = () => {
if (!claudeStatus?.installed) return null; if (!claudeStatus?.installed) return null;
@@ -163,7 +163,7 @@ export default function ClaudeToolCard({
}; };
// Generate settings.json content for manual copy // Generate settings.json content for manual copy
const getSettingsContent = () => { const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim()) const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey ? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>"); : (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
@@ -172,17 +172,13 @@ export default function ClaudeToolCard({
const targetModel = modelMappings[model.alias]; const targetModel = modelMappings[model.alias];
if (targetModel && model.envKey) env[model.envKey] = targetModel; if (targetModel && model.envKey) env[model.envKey] = targetModel;
}); });
return JSON.stringify({ env }, null, 2);
};
const copyToClipboard = async (text) => { return [
try { {
await navigator.clipboard.writeText(text); filename: "~/.claude/settings.json",
setCopiedConfig(true); content: JSON.stringify({ env }, null, 2),
setTimeout(() => setCopiedConfig(false), 2000); },
} catch (err) { ];
console.log("Failed to copy:", err);
}
}; };
return ( return (
@@ -289,31 +285,23 @@ export default function ClaudeToolCard({
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!claudeStatus?.has9Router} loading={restoring}> <Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!claudeStatus?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset <span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button> </Button>
<Button variant="ghost" size="sm" onClick={checkClaudeStatus}> <Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px]">refresh</span> <span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button> </Button>
</div> </div>
{/* Manual Config Section */}
<div className="pt-4 border-t border-border flex flex-col gap-3">
<p className="text-xs text-text-muted">Or copy config manually:</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-text-main">~/.claude/settings.json</span>
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(getSettingsContent())}>
<span className="material-symbols-outlined text-[14px] mr-1">{copiedConfig ? "check" : "content_copy"}</span>
{copiedConfig ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-40 overflow-y-auto">{getSettingsContent()}</pre>
</div>
</div>
</> </>
)} )}
</div> </div>
)} )}
<ModelSelectModal isOpen={modalOpen} onClose={() => setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} /> <ModelSelectModal isOpen={modalOpen} onClose={() => setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} />
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Claude CLI - Manual Configuration"
configs={getManualConfigs()}
/>
</Card> </Card>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; 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"; import Image from "next/image";
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled }) { 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 [showInstallGuide, setShowInstallGuide] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState(""); const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState(""); const [selectedModel, setSelectedModel] = useState("");
const [copiedConfig, setCopiedConfig] = useState(false);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({}); const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
useEffect(() => { useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) { if (apiKeys?.length > 0 && !selectedApiKey) {
@@ -123,7 +123,12 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
setModalOpen(false); setModalOpen(false);
}; };
const configContent = `# 9Router Configuration for Codex CLI const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const configContent = `# 9Router Configuration for Codex CLI
model = "${selectedModel}" model = "${selectedModel}"
model_provider = "9router" model_provider = "9router"
@@ -133,22 +138,20 @@ base_url = "${baseUrl}/v1"
wire_api = "responses" wire_api = "responses"
`; `;
const keyToUse = (selectedApiKey && selectedApiKey.trim()) const authContent = JSON.stringify({
? selectedApiKey OPENAI_API_KEY: keyToUse
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>"); }, null, 2);
const authContent = JSON.stringify({ return [
OPENAI_API_KEY: keyToUse {
}, null, 2); filename: "~/.codex/config.toml",
content: configContent,
const copyToClipboard = async (text) => { },
try { {
await navigator.clipboard.writeText(text); filename: "~/.codex/auth.json",
setCopiedConfig(true); content: authContent,
setTimeout(() => setCopiedConfig(false), 2000); },
} catch (err) { ];
console.log("Failed to copy:", err);
}
}; };
return ( return (
@@ -257,39 +260,12 @@ wire_api = "responses"
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!codexStatus.has9Router} loading={restoring}> <Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!codexStatus.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset <span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button> </Button>
<Button variant="ghost" size="sm" onClick={checkCodexStatus}> <Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px]">refresh</span> <span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button> </Button>
</div> </div>
</> </>
)} )}
{/* Manual Config Section */}
<div className="pt-4 border-t border-border flex flex-col gap-3">
<p className="text-xs text-text-muted">Or copy config manually:</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-text-main">~/.codex/config.toml</span>
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(configContent)}>
<span className="material-symbols-outlined text-[14px] mr-1">{copiedConfig ? "check" : "content_copy"}</span>
{copiedConfig ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-32 overflow-y-auto">{configContent}</pre>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-text-main">~/.codex/auth.json</span>
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(authContent)}>
<span className="material-symbols-outlined text-[14px] mr-1">{copiedConfig ? "check" : "content_copy"}</span>
{copiedConfig ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-32 overflow-y-auto">{authContent}</pre>
</div>
</div>
</div> </div>
)} )}
@@ -302,6 +278,13 @@ wire_api = "responses"
modelAliases={modelAliases} modelAliases={modelAliases}
title="Select Model for Codex" title="Select Model for Codex"
/> />
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Codex CLI - Manual Configuration"
configs={getManualConfigs()}
/>
</Card> </Card>
); );
} }

View File

@@ -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 (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="xl">
<div className="flex flex-col gap-4">
{configs.map((config, index) => (
<div key={index} className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-text-main">{config.filename}</span>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(config.content, index)}
>
<span className="material-symbols-outlined text-[14px] mr-1">
{copiedIndex === index ? "check" : "content_copy"}
</span>
{copiedIndex === index ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto border border-border">
{config.content}
</pre>
</div>
))}
</div>
</Modal>
);
}

View File

@@ -15,6 +15,7 @@ export { default as Header } from "./Header";
export { default as Footer } from "./Footer"; export { default as Footer } from "./Footer";
export { default as OAuthModal } from "./OAuthModal"; export { default as OAuthModal } from "./OAuthModal";
export { default as ModelSelectModal } from "./ModelSelectModal"; export { default as ModelSelectModal } from "./ModelSelectModal";
export { default as ManualConfigModal } from "./ManualConfigModal";
export { default as UsageStats } from "./UsageStats"; export { default as UsageStats } from "./UsageStats";
export { default as RequestLogger } from "./RequestLogger"; export { default as RequestLogger } from "./RequestLogger";

View File

@@ -61,19 +61,19 @@ export async function handleChat(request, clientRawRequest = null) {
return handleComboChat({ return handleComboChat({
body, body,
models: comboModels, models: comboModels,
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest), handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request),
log log
}); });
} }
// Single model request // Single model request
return handleSingleModelChat(body, modelStr, clientRawRequest); return handleSingleModelChat(body, modelStr, clientRawRequest, request);
} }
/** /**
* Handle single model chat 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); const modelInfo = await getModelInfo(modelStr);
if (!modelInfo.provider) { if (!modelInfo.provider) {
log.warn("CHAT", "Invalid model format", { model: modelStr }); 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}`); 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) // Try with available accounts (fallback on errors)
let excludeConnectionId = null; let excludeConnectionId = null;
let lastError = null; let lastError = null;
@@ -121,6 +124,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null) {
log, log,
clientRawRequest, clientRawRequest,
connectionId: credentials.connectionId, connectionId: credentials.connectionId,
userAgent,
onCredentialsRefreshed: async (newCreds) => { onCredentialsRefreshed: async (newCreds) => {
await updateProviderCredentials(credentials.connectionId, { await updateProviderCredentials(credentials.connectionId, {
accessToken: newCreds.accessToken, accessToken: newCreds.accessToken,