From 0357a18cead813a8164644fd9a13ca054c98a9cb Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:02:26 +0700 Subject: [PATCH] fix: replace unit test with integration test for proactive context compression (#1378) Integrated into release/v3.6.8 --- CHANGELOG.md | 27 +- bin/nodeRuntimeSupport.mjs | 16 +- open-sse/config/imageRegistry.ts | 28 ++ open-sse/executors/perplexity-web.ts | 4 - open-sse/handlers/chatCore.ts | 19 +- open-sse/handlers/responseSanitizer.ts | 2 +- open-sse/services/bailianQuotaFetcher.ts | 11 +- open-sse/services/claudeCodeCompatible.ts | 22 +- open-sse/services/contextManager.ts | 41 ++- open-sse/services/provider.ts | 10 +- open-sse/services/tokenRefresh.ts | 67 ++-- open-sse/services/wildcardRouter.ts | 22 +- open-sse/translator/helpers/claudeHelper.ts | 68 +++- open-sse/utils/proxyFetch.ts | 8 +- open-sse/utils/tlsClient.ts | 4 +- open-sse/utils/usageTracking.ts | 22 +- .../dashboard/audit/ConfigAuditViewer.tsx | 177 +++------- src/app/(dashboard)/dashboard/audit/page.tsx | 5 +- src/app/api/audit/route.ts | 29 +- src/app/api/v1/images/generations/route.ts | 36 ++ src/app/api/v1/music/generations/route.ts | 4 + src/app/api/v1/search/route.ts | 30 +- src/app/api/v1/videos/generations/route.ts | 4 + src/lib/usage/callLogArtifacts.ts | 3 +- src/lib/usage/usageStats.ts | 45 ++- src/shared/components/OAuthModal.tsx | 8 +- src/shared/components/PricingModal.tsx | 2 +- src/shared/validation/schemas.ts | 2 +- tests/e2e/memory-settings.spec.ts | 10 +- tests/e2e/settings-toggles.spec.ts | 14 +- tests/e2e/skills-marketplace.spec.ts | 12 +- .../chatcore-compression-integration.test.ts | 331 ++++++++++++++++++ tests/integration/proxy-pipeline.test.ts | 2 +- tests/unit/bailian-quota-fetcher.test.ts | 8 +- tests/unit/chat-route-edge-cases.test.ts | 3 +- .../chatcore-compression-integration.test.ts | 15 +- tests/unit/context-manager.test.ts | 14 + tests/unit/db-core-init.test.ts | 2 +- tests/unit/db-migration-runner.test.ts | 28 +- tests/unit/executor-antigravity.test.ts | 10 + tests/unit/image-generation-route.test.ts | 113 ++++++ tests/unit/model-sync-route.test.ts | 5 +- tests/unit/node-runtime-support.test.ts | 35 +- tests/unit/prompt-required-routes.test.ts | 49 +++ tests/unit/search-route.test.ts | 148 ++++++++ tests/unit/usage-fetcher-antigravity.test.ts | 8 +- tests/unit/usage-service-hardening.test.ts | 9 +- 47 files changed, 1235 insertions(+), 297 deletions(-) create mode 100644 tests/integration/chatcore-compression-integration.test.ts create mode 100644 tests/unit/image-generation-route.test.ts create mode 100644 tests/unit/prompt-required-routes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d84e3deb..8c065678f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,27 @@ - **feat(i18n):** Add internationalization support for combo features and dashboard components; sync translations across 31 keys (#1318) - **feat(providers):** Add Claude Opus 4.7 to Claude Code OAuth models natively with extended context and caching (#1347) - **feat(core):** Add stopSequences support and expand tool definitions to include Google Search capabilities -- **security:** Resolve GitHub CodeQL scan alerts and enforce deep SSRF mitigations +- **feat(auth):** Enforce dashboard session authentication on all management API routes, preventing unauthenticated access to configuration endpoints +- **feat(runtime):** Add hot-reloadable guardrails and model diagnostics for real-time rule evaluation without restarts +- **feat(core):** Add payload rules, tag-based routing, and scheduled budget systems for fine-grained request governance +- **feat(providers):** Expose Antigravity preview model aliases and Gemini CLI onboarding flow for first-time setup +- **feat(antigravity):** Add client model aliases and thoughtSignature bypass modes for Antigravity OAuth connections +- **feat(providers):** Expand image provider registry with extended model support including SD3.5, FLUX, and DALL-E 3 HD configurations +- **feat(combos):** Add new routing strategies and full i18n support for agent features section across 31 languages + +### 🔒 Security + +- **security:** Resolve 18 GitHub CodeQL scan alerts including ReDoS, incomplete sanitization, and bad HTML filtering regexp patterns +- **fix(auth):** Seal privilege escalation vector by enforcing JWT session checking exclusively on `/api/keys` management endpoints (#1353) +- **fix(providers):** Resolve Codex token refresh race condition via mutex `getAccessToken` preventing `refresh_token_reused` Auth0 revocations ### 🔧 Maintenance & Architecture - **refactor(core):** Split CLI runner and decouple migration engine for extensibility (#1358) +- **refactor(audit):** Rewire audit dashboard from dead in-memory `configAudit` store to live SQLite `audit_log` table — 331+ hidden compliance entries now visible in `/dashboard/audit` +- **build(deps):** Bump `softprops/action-gh-release` from v2 to v3 +- **ci:** Bump GitHub Actions CI node-version to Node.js 24 natively +- **fix(types):** Resolve TypeScript compilation errors in `claudeCodeCompatible.ts` (type predicates, `cache_control` index access) and `proxyFetch.ts` (`signal` nullability) ### 🐛 Bug Fixes @@ -29,8 +45,6 @@ - **fix(mcp):** Checkpoint and close MCP audit SQLite database safely on process signals and shutdown (#1348) - **fix(mcp):** Fully decouple MCP audit SQLite connection caching via globalThis to fix unhandled teardown in standalone Next.js chunks (#1349) - **fix(cli):** Avoid creating app router directory during postinstall initialization on non-built source trees (#1351) -- **fix(providers):** Resolve token refresh race condition causing Codex accounts to be erroneously flagged with `refresh_token_reused` by Auth0 -- **fix(auth):** Seal privilege escalation vector by enforcing JWT session checking exclusively on `/api/keys` management endpoints (#1353) - **fix(codex):** Correctly translate `system` role to `developer` in input array to unlock GPT-5 automatic prompt caching (#1346) - **fix(core):** Pass client headers to executor in chatCore (#1335) - **fix(providers):** Separate test batch calls and ignore unknown connections @@ -38,11 +52,18 @@ - **fix(db):** Preserve key_value settings (dashboard passwords, saved aliases) across DB heuristic recreation cycles (#1333) - **fix(routing):** Allow combo fallback to cascade context overflow 400 errors instead of immediate aborts (#1331) - **fix(core):** Resolve thinking leaks, consecutive roles, and missing thoughtSignatures for Antigravity translator (#1316) +- **fix(translator):** Only apply thoughtSignature to the first `functionCall` part in Gemini parallel tool calls, preventing duplicate signatures - **fix(providers):** Default to batch testing execution blocks for web, search, and audio modalities to prevent connection timeouts - **fix(cli):** Resolve Node 22 TS entrypoint incompatibility by using esbuild compilation (#1315) - **fix(chat):** Preserve max_output_tokens for Responses API targets in chatCore sanitization (#1313) - **fix(api):** API Manager usage stats showing 0 for all registered keys (#1310) +- **fix(api):** Support image-only models in catalog and allow authless search providers to bypass validation requirements +- **fix(routes):** Require prompts for media generation requests (`/images`, `/videos`, `/music`), returning 400 on missing payloads - **fix(dashboard):** Auto-scroll ActivityHeatmap to show current date (#1309) +- **fix(dashboard):** Restore horizontal layout with `w-max` wrapper in heatmap components +- **fix(i18n):** Update `nodeIncompatibleHint` to recommend Node 24 LTS across all 31 languages +- **fix(i18n):** Add Chinese i18n support to remaining dashboard components (`Loading.tsx`, `DataTable`, etc.) +- **fix(requestLogger):** Add missing `cacheSource` and `tps` columns to i18n log detail views ## [3.6.6] — 2026-04-15 diff --git a/bin/nodeRuntimeSupport.mjs b/bin/nodeRuntimeSupport.mjs index ea6c66d8b5..8c203f0722 100644 --- a/bin/nodeRuntimeSupport.mjs +++ b/bin/nodeRuntimeSupport.mjs @@ -3,11 +3,13 @@ export const SECURE_NODE_LINES = Object.freeze([ Object.freeze({ major: 20, minor: 20, patch: 2 }), Object.freeze({ major: 22, minor: 22, patch: 2 }), + Object.freeze({ major: 24, minor: 0, patch: 0 }), ]); -export const RECOMMENDED_NODE_VERSION = "22.22.2"; -export const SUPPORTED_NODE_RANGE = ">=20.20.2 <21 || >=22.22.2 <23"; -export const SUPPORTED_NODE_DISPLAY = "Node.js 20.20.2+ (20.x LTS) or 22.22.2+ (22.x LTS)"; +export const RECOMMENDED_NODE_VERSION = "24.14.1"; +export const SUPPORTED_NODE_RANGE = ">=20.20.2 <21 || >=22.22.2 <23 || >=24.0.0 <25"; +export const SUPPORTED_NODE_DISPLAY = + "Node.js 20.20.2+ (20.x LTS), 22.22.2+ (22.x LTS), or 24.0.0+ (24.x LTS)"; function formatVersion(version) { return `${version.major}.${version.minor}.${version.patch}`; @@ -50,8 +52,8 @@ export function getNodeRuntimeSupport(version = process.versions.node) { reason = "supported"; } else if (secureFloor) { reason = "below-security-floor"; - } else if (parsed.major >= 24) { - reason = "native-addon-incompatible"; + } else if (parsed.major >= 25) { + reason = "unreleased-major"; } return { @@ -73,8 +75,8 @@ export function getNodeRuntimeWarning(version = process.versions.node) { return `Node.js ${support.nodeVersion} is below the patched minimum ${support.minimumSecureVersion} for this LTS line.`; } - if (support.reason === "native-addon-incompatible") { - return `Node.js ${support.nodeVersion} is outside the supported LTS lines and may fail at runtime because better-sqlite3 does not support Node.js 24+ here.`; + if (support.reason === "unreleased-major") { + return `Node.js ${support.nodeVersion} is outside the supported LTS lines. OmniRoute currently supports Node.js 20.x, 22.x, and 24.x.`; } return `Node.js ${support.nodeVersion} is outside OmniRoute's approved secure runtime policy.`; diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index f5df635ec5..f33f8e2955 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -493,3 +493,31 @@ export function getAllImageModels() { export function getImageModelAliases() { return IMAGE_MODEL_ALIASES; } + +export function getImageModelEntry(modelStr) { + if (!modelStr) return null; + + const alias = IMAGE_MODEL_ALIASES[modelStr]; + if (alias) { + const modelConfig = findImageModelConfig(alias.provider, alias.model); + return { + provider: alias.provider, + model: alias.model, + inputModalities: alias.inputModalities || modelConfig?.inputModalities || ["text"], + description: alias.description || modelConfig?.description || undefined, + }; + } + + const { provider, model } = parseImageModel(modelStr); + if (!provider || !model) return null; + + const modelConfig = findImageModelConfig(provider, model); + if (!modelConfig) return null; + + return { + provider, + model, + inputModalities: modelConfig.inputModalities || ["text"], + description: modelConfig.description || undefined, + }; +} diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index fd270fe055..a167136ee2 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -33,8 +33,6 @@ const CITATION_RE = /\[\d+\]/g; const GROK_TAG_RE = /]*>.*?<\/grok:[^>]*>/gs; const GROK_SELF_RE = /]*\/>/g; const XML_DECL_RE = /<[?]xml[^?]*[?]>/g; -const SCRIPT_RE = /]*>.*?<\/script>/gis; -const SCRIPT_TAG_RE = /<\/?script\b[^>]*>/gi; const RESPONSE_TAG_RE = /<\/?response\b[^>]*>/gi; const MULTI_SPACE = / {2,}/g; const MULTI_NL = /\n{3,}/g; @@ -109,8 +107,6 @@ function cleanResponse(text: string, strip = true): string { t = t.replace(GROK_TAG_RE, ""); t = t.replace(GROK_SELF_RE, ""); t = t.replace(RESPONSE_TAG_RE, ""); - t = t.replace(SCRIPT_RE, ""); // lgtm[js/incomplete-multi-character-sanitization] - t = t.replace(SCRIPT_TAG_RE, ""); // lgtm[js/incomplete-multi-character-sanitization] if (strip) { t = t.replace(MULTI_SPACE, " "); t = t.replace(MULTI_NL, "\n\n"); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 13693d0560..a3d8e48378 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1452,12 +1452,20 @@ export async function handleChatCore({ } } + // ── Proactive Context Compression (Phase 4) ── + // Check if context exceeds 85% of limit and compress proactively before sending to provider. + // This prevents "prompt too long" errors for large-but-not-full contexts. if (translatedBody && translatedBody.messages && Array.isArray(translatedBody.messages)) { const estimatedTokens = estimateTokens(JSON.stringify(translatedBody.messages)); const contextLimit = getTokenLimit(provider, effectiveModel); const COMPRESSION_THRESHOLD = 0.85; const threshold = Math.floor(contextLimit * COMPRESSION_THRESHOLD); + log?.debug?.( + "CONTEXT", + `Checking compression: ${estimatedTokens} tokens vs ${threshold} threshold (${contextLimit} limit)` + ); + if (estimatedTokens > threshold) { log?.info?.( "CONTEXT", @@ -1468,6 +1476,7 @@ export async function handleChatCore({ provider, model: effectiveModel, maxTokens: contextLimit, + reserveTokens: 0, }); if (compressionResult.compressed) { @@ -1495,8 +1504,15 @@ export async function handleChatCore({ layers: "layers" in stats ? stats.layers : undefined, }, }); + } else { + log?.debug?.("CONTEXT", `Compression not applied: context already fits within target`); } } + } else { + log?.debug?.( + "CONTEXT", + `Skipping compression check: translatedBody=${!!translatedBody}, messages=${!!translatedBody?.messages}, isArray=${Array.isArray(translatedBody?.messages)}` + ); } // Resolve executor with optional upstream proxy (CLIProxyAPI) routing. @@ -1840,7 +1856,8 @@ export async function handleChatCore({ const newCredentials = (await refreshWithRetry( () => executor.refreshCredentials(credentials, log), 3, - log + log, + provider // Explicitly pass the provider to avoid universally tripping the "unknown" circuit breaker )) as null | { accessToken?: string; copilotToken?: string; diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 94612cfee9..aa2c0f1e9d 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -464,7 +464,7 @@ function sanitizeResponsesOutputItem(item: unknown, index: number): JsonRecord | text: collapseExcessiveNewlines(toString(partRecord.text) || ""), }; }) - .filter((part): part is JsonRecord => part !== null) + .filter((part): part is { type: string; text: string } => part !== null) : []; return { diff --git a/open-sse/services/bailianQuotaFetcher.ts b/open-sse/services/bailianQuotaFetcher.ts index a10c0f93f6..3eb0c11847 100644 --- a/open-sse/services/bailianQuotaFetcher.ts +++ b/open-sse/services/bailianQuotaFetcher.ts @@ -94,7 +94,16 @@ function getAuthKey( } function getHost(): string { - return process.env.ALIBABA_CODING_PLAN_HOST || BAILIAN_QUOTA_HOSTS.international; + const configuredHost = process.env.ALIBABA_CODING_PLAN_HOST?.trim(); + if (!configuredHost) { + return BAILIAN_QUOTA_HOSTS.international; + } + + if (/^https?:\/\//i.test(configuredHost)) { + return configuredHost; + } + + return `https://${configuredHost}`; } function getQuotaUrl(): string { diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index f2b5b44773..bbb115cd30 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -437,11 +437,16 @@ function buildClaudeCodeCompatibleMessages(messages: MessageLike[]) { .filter( ( message - ): message is { role: "user" | "assistant"; content: Array> } => - !!message && message.content.length > 0 + ): message is { + role: "user" | "assistant"; + content: Array<{ type: string; text: string }>; + } => !!message && message.content.length > 0 ); - const merged: Array<{ role: "user" | "assistant"; content: Array> }> = []; + const merged: Array<{ + role: "user" | "assistant"; + content: Array<{ type: string; text: string }>; + }> = []; for (const message of converted) { const last = merged[merged.length - 1]; @@ -575,7 +580,7 @@ function buildClaudeCodeCompatibleSystemBlocks({ for (const systemBlock of customSystemBlocks) { const preparedBlock = { ...systemBlock }; if (!preserveCacheControl) { - delete preparedBlock.cache_control; + delete preparedBlock["cache_control"]; } blocks.push(preparedBlock); } @@ -700,9 +705,12 @@ function prepareClaudeCodeCompatibleBody( const prepared = prepareClaudeRequest( { system: normalizeClaudeSystemInput(claudeBody.system), - messages: normalizeClaudeMessageInput(claudeBody.messages), + messages: normalizeClaudeMessageInput(claudeBody.messages) as Array<{ + role?: string; + content?: string | Array>; + }>, tools: normalizeClaudeToolInput(claudeBody.tools), - thinking: readRecord(claudeBody.thinking) || claudeBody.thinking, + thinking: (readRecord(claudeBody.thinking) || null) as Record | null, }, CLAUDE_CODE_COMPATIBLE_PREFIX, true @@ -735,7 +743,7 @@ function normalizeClaudeMessageInput(messages: unknown) { content: normalizeClaudeContentInput(record.content), }; }) - .filter((message): message is Record => !!message); + .filter((message): message is Record & { content: unknown } => !!message); } function normalizeClaudeToolInput(tools: unknown) { diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index d9bd126f7e..25bf6cc033 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -6,7 +6,7 @@ */ import { REGISTRY } from "../config/providerRegistry.ts"; -import { getModelContextLimit } from "../../src/lib/modelCapabilities"; +import { getModelContextLimit } from "../../src/lib/modelCapabilities.ts"; // Default token limits per provider (fallbacks when not in registry) const DEFAULT_LIMITS: Record = { @@ -34,6 +34,16 @@ function getEnvOverride(provider: string): number | null { return null; } +// Reserve tokens override from environment variable +function getReserveTokensOverride(): number | null { + const envValue = process.env.CONTEXT_RESERVE_TOKENS; + if (envValue) { + const parsed = parseInt(envValue, 10); + if (!isNaN(parsed) && parsed > 0) return parsed; + } + return null; +} + // Rough chars-per-token ratio for quick estimation const CHARS_PER_TOKEN = 4; @@ -109,8 +119,12 @@ export function compressContext( const provider = options.provider || "default"; const maxTokens = options.maxTokens || getTokenLimit(provider, (body.model as string) || options.model || null); - const reserveTokens = options.reserveTokens || 16000; // Reserve for response - const targetTokens = maxTokens - reserveTokens; + const defaultReserveTokens = Math.min(16000, Math.max(256, Math.floor(maxTokens * 0.15))); + const reserveTokens = Math.min( + options.reserveTokens ?? getReserveTokensOverride() ?? defaultReserveTokens, + Math.max(0, maxTokens - 1) + ); + const targetTokens = Math.max(0, maxTokens - reserveTokens); let messages = [...body.messages]; let currentTokens = estimateTokens(JSON.stringify(messages)); @@ -216,10 +230,23 @@ function compressThinking(messages: Record[]) { // Remove thinking XML tags from string content if (typeof msg.content === "string") { - const cleaned = msg.content - .replace(/.*?<\/thinking>/gs, "") - .replace(/.*?<\/antThinking>/gs, "") - .trim(); + let cleaned = msg.content; + for (const [start, end] of [ + ["", ""], + ["", ""], + ]) { + while (true) { + const s = cleaned.indexOf(start); + if (s === -1) break; + const e = cleaned.indexOf(end, s + start.length); + if (e === -1) { + cleaned = cleaned.slice(0, s); + break; + } + cleaned = cleaned.slice(0, s) + cleaned.slice(e + end.length); + } + } + cleaned = cleaned.trim(); return { ...msg, content: cleaned || "[thinking compressed]" }; } diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index 79bf1e6ea4..e92c943998 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -29,7 +29,10 @@ export function isClaudeCodeCompatible(provider) { return typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX); } -export function getOpenAICompatibleType(provider, providerSpecificData = null) { +export function getOpenAICompatibleType( + provider, + providerSpecificData: Record | null = null +) { if (!isOpenAICompatible(provider)) return "chat"; const configuredType = providerSpecificData && @@ -277,7 +280,10 @@ export function buildProviderUrl( } // Custom URL builder (e.g. gemini, gemini-cli) if (entry.urlBuilder) { - return entry.urlBuilder(entry.baseUrl, model, stream); + const baseUrl = entry.baseUrl || config.baseUrl; + if (baseUrl) { + return entry.urlBuilder(baseUrl, model, stream); + } } // URL suffix (e.g. claude: ?beta=true) if (entry.urlSuffix) { diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 7e1c68acfc..d95c9c8f49 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -12,6 +12,23 @@ const CACHE_SECRET = "omniroute-token-cache"; // Key: "provider:sha256(refreshToken)" → Value: Promise const refreshPromiseCache = new Map(); +type RefreshLogger = { + info?: (tag: string, message: string, data?: Record) => void; + warn?: (tag: string, message: string, data?: Record) => void; + error?: (tag: string, message: string, data?: Record) => void; + debug?: (tag: string, message: string, data?: Record) => void; +} | null; + +function buildFormParams(entries: Record): URLSearchParams { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(entries)) { + if (typeof value === "string" && value.length > 0) { + params.set(key, value); + } + } + return params; +} + function getRefreshCacheKey(provider, refreshToken) { const tokenHash = pbkdf2Sync(refreshToken, CACHE_SECRET, 1000, 32, "sha256").toString("hex"); return `${provider}:${tokenHash}`; @@ -25,7 +42,7 @@ export async function refreshAccessToken( refreshToken, credentials, log, - proxyConfig = null + proxyConfig: unknown = null ) { const config = PROVIDERS[provider]; @@ -93,7 +110,7 @@ export async function refreshAccessToken( * Specialized refresh for Cline OAuth tokens. * Cline refresh endpoint expects JSON body and returns camelCase fields. */ -export async function refreshClineToken(refreshToken, log, proxyConfig = null) { +export async function refreshClineToken(refreshToken, log, proxyConfig: unknown = null) { const endpoint = PROVIDERS.cline?.refreshUrl; if (!endpoint) { log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Cline"); @@ -153,7 +170,7 @@ export async function refreshClineToken(refreshToken, log, proxyConfig = null) { * Specialized refresh for Kimi Coding OAuth tokens. * Uses custom X-Msh-* headers required by Kimi OAuth API. */ -export async function refreshKimiCodingToken(refreshToken, log, proxyConfig = null) { +export async function refreshKimiCodingToken(refreshToken, log, proxyConfig: unknown = null) { const endpoint = PROVIDERS["kimi-coding"]?.refreshUrl || PROVIDERS["kimi-coding"]?.tokenUrl; if (!endpoint) { log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Kimi Coding"); @@ -221,10 +238,10 @@ export async function refreshKimiCodingToken(refreshToken, log, proxyConfig = nu /** * Specialized refresh for Claude OAuth tokens */ -export async function refreshClaudeOAuthToken(refreshToken, log, proxyConfig = null) { +export async function refreshClaudeOAuthToken(refreshToken, log, proxyConfig: unknown = null) { try { // Standard OAuth2 token refresh uses form-urlencoded (not JSON) - const params = new URLSearchParams({ + const params = buildFormParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS.claude.clientId, @@ -278,7 +295,7 @@ export async function refreshGoogleToken( clientId, clientSecret, log, - proxyConfig = null + proxyConfig: unknown = null ) { const response = await runWithProxyContext(proxyConfig, () => fetch(OAUTH_ENDPOINTS.google.token, { @@ -287,7 +304,7 @@ export async function refreshGoogleToken( "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, - body: new URLSearchParams({ + body: buildFormParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId, @@ -320,10 +337,7 @@ export async function refreshGoogleToken( }; } -/** - * Specialized refresh for Qwen OAuth tokens - */ -export async function refreshQwenToken(refreshToken, log, proxyConfig = null) { +export async function refreshQwenToken(refreshToken, log, proxyConfig: unknown = null) { const endpoint = OAUTH_ENDPOINTS.qwen.token; try { @@ -334,7 +348,7 @@ export async function refreshQwenToken(refreshToken, log, proxyConfig = null) { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, - body: new URLSearchParams({ + body: buildFormParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS.qwen.clientId, @@ -403,7 +417,7 @@ export async function refreshQwenToken(refreshToken, log, proxyConfig = null) { * Returns { error: 'refresh_token_reused' } when the token has already been consumed, * so callers can stop retrying and request re-authentication. */ -export async function refreshCodexToken(refreshToken, log, proxyConfig = null) { +export async function refreshCodexToken(refreshToken, log, proxyConfig: unknown = null) { try { const response = await runWithProxyContext(proxyConfig, () => fetch(OAUTH_ENDPOINTS.openai.token, { @@ -412,7 +426,7 @@ export async function refreshCodexToken(refreshToken, log, proxyConfig = null) { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, - body: new URLSearchParams({ + body: buildFormParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS.codex.clientId, @@ -480,7 +494,7 @@ export async function refreshKiroToken( refreshToken, providerSpecificData, log, - proxyConfig = null + proxyConfig: unknown = null ) { try { const authMethod = providerSpecificData?.authMethod; @@ -537,8 +551,13 @@ export async function refreshKiroToken( } // Social Auth (Google/GitHub) - use Kiro's refresh endpoint + const tokenUrl = PROVIDERS.kiro.tokenUrl; + if (!tokenUrl) { + log?.error?.("TOKEN_REFRESH", "Missing Kiro token endpoint"); + return null; + } const response = await runWithProxyContext(proxyConfig, () => - fetch(PROVIDERS.kiro.tokenUrl, { + fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -580,7 +599,7 @@ export async function refreshKiroToken( /** * Specialized refresh for Qoder OAuth tokens */ -export async function refreshQoderToken(refreshToken, log, proxyConfig = null) { +export async function refreshQoderToken(refreshToken, log, proxyConfig: unknown = null) { if (!OAUTH_ENDPOINTS.qoder.token || !PROVIDERS.qoder.clientId || !PROVIDERS.qoder.clientSecret) { log?.warn?.( "TOKEN_REFRESH", @@ -599,7 +618,7 @@ export async function refreshQoderToken(refreshToken, log, proxyConfig = null) { Accept: "application/json", Authorization: `Basic ${basicAuth}`, }, - body: new URLSearchParams({ + body: buildFormParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS.qoder.clientId, @@ -635,7 +654,7 @@ export async function refreshQoderToken(refreshToken, log, proxyConfig = null) { /** * Specialized refresh for GitHub Copilot OAuth tokens */ -export async function refreshGitHubToken(refreshToken, log, proxyConfig = null) { +export async function refreshGitHubToken(refreshToken, log, proxyConfig: unknown = null) { const response = await runWithProxyContext(proxyConfig, () => fetch(OAUTH_ENDPOINTS.github.token, { method: "POST", @@ -643,7 +662,7 @@ export async function refreshGitHubToken(refreshToken, log, proxyConfig = null) "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, - body: new URLSearchParams({ + body: buildFormParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS.github.clientId, @@ -679,7 +698,7 @@ export async function refreshGitHubToken(refreshToken, log, proxyConfig = null) /** * Refresh GitHub Copilot token using GitHub access token */ -export async function refreshCopilotToken(githubAccessToken, log, proxyConfig = null) { +export async function refreshCopilotToken(githubAccessToken, log, proxyConfig: unknown = null) { try { const response = await runWithProxyContext(proxyConfig, () => fetch("https://api.github.com/copilot_internal/v2/token", { @@ -718,7 +737,7 @@ export async function refreshCopilotToken(githubAccessToken, log, proxyConfig = /** * Get access token for a specific provider (internal, does the actual work) */ -async function _getAccessTokenInternal(provider, credentials, log, proxyConfig = null) { +async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: unknown = null) { switch (provider) { case "gemini": case "gemini-cli": @@ -807,7 +826,7 @@ export function isUnrecoverableRefreshError(result) { * subsequent calls share the existing promise instead of making * parallel OAuth requests. */ -export async function getAccessToken(provider, credentials, log, proxyConfig = null) { +export async function getAccessToken(provider, credentials, log, proxyConfig: unknown = null) { if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") { log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`); return null; @@ -1034,7 +1053,7 @@ async function withTimeout(fn: () => Promise, timeoutMs: number): Promise< export async function refreshWithRetry( refreshFn, maxRetries = 3, - log = null, + log: RefreshLogger = null, provider = "unknown" ) { // Circuit breaker check diff --git a/open-sse/services/wildcardRouter.ts b/open-sse/services/wildcardRouter.ts index accfd73ab4..b60216f0fa 100644 --- a/open-sse/services/wildcardRouter.ts +++ b/open-sse/services/wildcardRouter.ts @@ -63,10 +63,13 @@ export function getSpecificity(pattern) { * @param {Array<{ pattern: string, target: string, [key: string]: unknown }>} aliases - Alias entries * @returns {{ pattern: string, target: string, specificity: number } | null} */ -export function resolveWildcardAlias(model, aliases) { +export function resolveWildcardAlias( + model: string, + aliases: WildcardAliasEntry[] +): ResolvedWildcardAlias | null { if (!model || !aliases || !Array.isArray(aliases)) return null; - const matches = []; + const matches: ResolvedWildcardAlias[] = []; for (const alias of aliases) { const pattern = alias.pattern || alias.alias || alias.from; const target = alias.target || alias.model || alias.to; @@ -115,3 +118,18 @@ export function resolveModel(model, exactAliases = {}, wildcardAliases = []) { // 3. Return original return model; } +type WildcardAliasEntry = { + pattern?: string; + alias?: string; + from?: string; + target?: string; + model?: string; + to?: string; + [key: string]: unknown; +}; + +type ResolvedWildcardAlias = WildcardAliasEntry & { + pattern: string; + target: string; + specificity: number; +}; diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 0b1a614ecb..2936b60240 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -1,8 +1,40 @@ // Claude helper functions for translator import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; +type ClaudeContentBlock = { + type?: string; + text?: string; + name?: string; + tool_use_id?: string; + cache_control?: unknown; + signature?: string; + thinking?: string; + [key: string]: unknown; +}; + +type ClaudeMessage = { + role?: string; + content?: string | ClaudeContentBlock[]; + [key: string]: unknown; +}; + +type ClaudeTool = { + name?: string; + defer_loading?: boolean; + cache_control?: unknown; + [key: string]: unknown; +}; + +type ClaudeRequestBody = { + system?: Array & { cache_control?: unknown }>; + messages?: ClaudeMessage[]; + tools?: ClaudeTool[]; + thinking?: Record | null; + [key: string]: unknown; +}; + // Check if message has valid non-empty content -export function hasValidContent(msg) { +export function hasValidContent(msg: ClaudeMessage): boolean { if (typeof msg.content === "string" && msg.content.trim()) return true; if (Array.isArray(msg.content)) { return msg.content.some( @@ -18,7 +50,7 @@ export function hasValidContent(msg) { // Fix tool_use/tool_result ordering for Claude API // 1. Assistant message with tool_use: remove text AFTER tool_use (Claude doesn't allow) // 2. Merge consecutive same-role messages -export function fixToolUseOrdering(messages) { +export function fixToolUseOrdering(messages: ClaudeMessage[]): ClaudeMessage[] { if (messages.length <= 1) return messages; // Pass 1: Fix assistant messages with tool_use - remove text after tool_use @@ -27,7 +59,7 @@ export function fixToolUseOrdering(messages) { const hasToolUse = msg.content.some((b) => b.type === "tool_use"); if (hasToolUse) { // Keep only: thinking blocks + tool_use blocks (remove text blocks after tool_use) - const newContent = []; + const newContent: ClaudeContentBlock[] = []; let foundToolUse = false; for (const block of msg.content) { @@ -49,7 +81,7 @@ export function fixToolUseOrdering(messages) { } // Pass 2: Merge consecutive same-role messages - const merged = []; + const merged: ClaudeMessage[] = []; for (const msg of messages) { const last = merged[merged.length - 1]; @@ -86,7 +118,7 @@ export function fixToolUseOrdering(messages) { return merged; } -function ensureMessageContentArray(msg) { +function ensureMessageContentArray(msg: ClaudeMessage): ClaudeContentBlock[] { if (Array.isArray(msg?.content)) return msg.content; if (typeof msg?.content === "string" && msg.content.trim()) { msg.content = [{ type: "text", text: msg.content }]; @@ -95,7 +127,7 @@ function ensureMessageContentArray(msg) { return []; } -function markMessageCacheControl(msg, ttl) { +function markMessageCacheControl(msg: ClaudeMessage, ttl?: string): boolean { const content = ensureMessageContentArray(msg); if (content.length === 0) return false; const lastIndex = content.length - 1; @@ -109,13 +141,18 @@ function markMessageCacheControl(msg, ttl) { // - Filter empty messages // - Add thinking block for Anthropic endpoint (provider === "claude") // - Fix tool_use/tool_result ordering -export function prepareClaudeRequest(body, provider = null, preserveCacheControl = false) { +export function prepareClaudeRequest( + body: ClaudeRequestBody, + provider: string | null = null, + preserveCacheControl = false +): ClaudeRequestBody { // 1. System: remove all cache_control, add only to last block with ttl 1h // In passthrough mode, preserve existing cache_control markers - if (body.system && Array.isArray(body.system) && !preserveCacheControl) { - body.system = body.system.map((block, i) => { + const systemBlocks = body.system; + if (systemBlocks && Array.isArray(systemBlocks) && !preserveCacheControl) { + body.system = systemBlocks.map((block, i) => { const { cache_control, ...rest } = block; - if (i === body.system.length - 1) { + if (i === systemBlocks.length - 1) { return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } }; } return rest; @@ -125,7 +162,7 @@ export function prepareClaudeRequest(body, provider = null, preserveCacheControl // 2. Messages: process in optimized passes if (body.messages && Array.isArray(body.messages)) { const len = body.messages.length; - let filtered = []; + let filtered: ClaudeMessage[] = []; // Pass 1: remove cache_control + filter empty messages // In passthrough mode, preserve existing cache_control markers @@ -181,7 +218,7 @@ export function prepareClaudeRequest(body, provider = null, preserveCacheControl // - cache the last assistant turn so the next user turn can reuse it // Skip in passthrough mode to preserve client's cache_control markers if (!preserveCacheControl) { - const userMessageIndexes = filtered.reduce((indexes, msg, index) => { + const userMessageIndexes = filtered.reduce((indexes, msg, index) => { if (msg?.role === "user") indexes.push(index); return indexes; }, []); @@ -196,8 +233,9 @@ export function prepareClaudeRequest(body, provider = null, preserveCacheControl let lastAssistantProcessed = false; for (let i = filtered.length - 1; i >= 0; i--) { const msg = filtered[i]; + const content = ensureMessageContentArray(msg); - if (msg.role === "assistant" && Array.isArray(ensureMessageContentArray(msg))) { + if (msg.role === "assistant" && content.length > 0) { // Add cache_control to last block of first (from end) assistant with content // Skip in passthrough mode to preserve client's cache_control markers if (!preserveCacheControl && !lastAssistantProcessed && markMessageCacheControl(msg)) { @@ -210,7 +248,7 @@ export function prepareClaudeRequest(body, provider = null, preserveCacheControl let hasThinking = false; // Always replace signature for all thinking blocks - for (const block of msg.content) { + for (const block of content) { if (block.type === "thinking" || block.type === "redacted_thinking") { block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE; hasThinking = true; @@ -220,7 +258,7 @@ export function prepareClaudeRequest(body, provider = null, preserveCacheControl // Add thinking block if thinking enabled + has tool_use but no thinking if (thinkingEnabled && !hasThinking && hasToolUse) { - msg.content.unshift({ + content.unshift({ type: "thinking", thinking: ".", signature: DEFAULT_THINKING_CLAUDE_SIGNATURE, diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index ad1122c6b4..a883ecb594 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -82,7 +82,12 @@ function noProxyMatch(targetUrl) { // Support wildcard matching (e.g. 192.168.* or *.local) if (patternHost.includes("*")) { const regexStr = - "^" + patternHost.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*") + "$"; + "^" + + patternHost + .split("*") + .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join(".*") + + "$"; if (new RegExp(regexStr).test(hostname)) return true; } @@ -220,6 +225,7 @@ async function patchedFetch(input: RequestInfo | URL, options: FetchWithDispatch return await tlsClient.fetch(targetUrl, { ...options, headers: options.headers, + signal: options.signal ?? undefined, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/open-sse/utils/tlsClient.ts b/open-sse/utils/tlsClient.ts index 1674ed24c6..9e93c3cf71 100644 --- a/open-sse/utils/tlsClient.ts +++ b/open-sse/utils/tlsClient.ts @@ -80,6 +80,8 @@ class TlsClient { async getSession() { if (!this.available) return null; if (this.session) return this.session; + const createSessionFn = createSession; + if (!createSessionFn) return null; const proxy = getProxyFromEnv(); const sessionOpts: Record = { @@ -91,7 +93,7 @@ class TlsClient { console.log(`[TlsClient] Using proxy: ${proxy}`); } - this.session = await createSession(sessionOpts); + this.session = await createSessionFn(sessionOpts); console.log("[TlsClient] Session created (Chrome 124 TLS fingerprint)"); return this.session; } diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index e0f88ce79d..89b6f54d13 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -500,7 +500,13 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI /** * Log usage with cache info (green color) */ -export function logUsage(provider, usage, model = null, connectionId = null, apiKeyInfo = null) { +export function logUsage( + provider, + usage, + model: string | null = null, + connectionId: string | null = null, + apiKeyInfo = null +) { if (!usage || typeof usage !== "object") return; const p = provider?.toUpperCase() || "UNKNOWN"; @@ -510,7 +516,11 @@ export function logUsage(provider, usage, model = null, connectionId = null, api // - Claude: input_tokens, output_tokens const inTokens = getLoggedInputTokens(usage); const outTokens = getLoggedOutputTokens(usage); - const accountPrefix = connectionId ? connectionId.slice(0, 8) + "..." : "unknown"; + void apiKeyInfo; + const normalizedConnectionId = typeof connectionId === "string" ? connectionId : undefined; + const accountPrefix = normalizedConnectionId + ? normalizedConnectionId.slice(0, 8) + "..." + : "unknown"; let msg = `[${getTimeString()}] 📊 ${COLORS.green}[USAGE] ${p} | in=${inTokens} | out=${outTokens} | account=${accountPrefix}${COLORS.reset}`; @@ -540,5 +550,11 @@ export function logUsage(provider, usage, model = null, connectionId = null, api cacheCreation: cacheCreation || 0, reasoning: reasoning || 0, }; - appendRequestLog({ model, provider, connectionId, tokens, status: "200 OK" }).catch(() => {}); + appendRequestLog({ + model: typeof model === "string" ? model : undefined, + provider: typeof provider === "string" ? provider : undefined, + connectionId: normalizedConnectionId, + tokens, + status: "200 OK", + }).catch(() => {}); } diff --git a/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx b/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx index 728897e1a7..298c09b60a 100644 --- a/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx +++ b/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx @@ -3,25 +3,18 @@ import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; -interface ConfigDiff { - added: string[]; - removed: string[]; - changed: Array<{ key: string; from: any; to: any }>; - isEmpty: boolean; -} - interface AuditEntry { - id: string; + id: number; timestamp: string; action: string; - target: string; - targetId: string; - targetName: string; - source: string; - before: any; - after: any; - diff: ConfigDiff; - note: string | null; + actor: string; + target?: string; + resource_type?: string; + ip_address?: string; + status?: string; + request_id?: string; + details?: any; + metadata?: any; } export default function ConfigAuditViewer() { @@ -48,16 +41,17 @@ export default function ConfigAuditViewer() { }; const getActionColor = (action: string) => { - switch (action) { - case "create": - return "text-green-400 bg-green-400/10 border-green-500/20"; - case "update": - return "text-blue-400 bg-blue-400/10 border-blue-500/20"; - case "delete": - return "text-red-400 bg-red-400/10 border-red-500/20"; - default: - return "text-gray-400 bg-gray-400/10 border-gray-500/20"; + const act = action.toLowerCase(); + if (act.includes("success") || act.includes("create")) { + return "text-green-400 bg-green-400/10 border-green-500/20"; } + if (act.includes("update") || act.includes("modify")) { + return "text-blue-400 bg-blue-400/10 border-blue-500/20"; + } + if (act.includes("failed") || act.includes("delete")) { + return "text-red-400 bg-red-400/10 border-red-500/20"; + } + return "text-gray-400 bg-gray-400/10 border-gray-500/20"; }; if (loading) { @@ -98,8 +92,8 @@ export default function ConfigAuditViewer() { Timestamp Action Target - Resource - Source + Actor + Resource/IP Details @@ -120,20 +114,20 @@ export default function ConfigAuditViewer() { - {entry.target} - - - {entry.targetName} + {entry.target || "-"} - {entry.source} + {entry.actor} + + + {entry.resource_type || entry.ip_address || "-"} @@ -149,10 +143,10 @@ export default function ConfigAuditViewer() {

- {selectedEntry.action} {selectedEntry.target} + {selectedEntry.action}

- ID: {selectedEntry.targetId} • {selectedEntry.targetName} + Actor: {selectedEntry.actor} • Target: {selectedEntry.target || "N/A"}