diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 253fcb3466..84edc087fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,21 +71,50 @@ jobs: name: i18n-${{ matrix.lang }} path: result.txt - security: - name: Security Audit + advanced-security: + name: Advanced Security Scans runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + # 1. TRUFFLEHOG OSS + - name: TruffleHog Secret Scan + uses: trufflesecurity/trufflehog@main + with: + path: ./ + base: ${{ github.event.repository.default_branch }} + head: HEAD + extra_args: --only-verified + + # 2. SONARQUBE SCAN + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v4 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + + # 3. SNYK SCAN E VERIFICAÇÕES NATIVAS - uses: actions/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci + + # Mantendo as verificaΓ§Γ΅es nativas originais - name: Dependency audit run: npm audit --audit-level=high --omit=dev || true - name: Check for known vulnerabilities run: npx is-my-node-vulnerable || true + - name: Run Snyk Vulnerability checks + uses: snyk/actions/node@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --severity-threshold=high + build: name: Build runs-on: ubuntu-latest @@ -195,7 +224,7 @@ jobs: if: always() needs: - lint - - security + - advanced-security - build - test-unit - test-coverage @@ -229,7 +258,7 @@ jobs: echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY echo "| Lint | $(status '${{ needs.lint.result }}') |" >> $GITHUB_STEP_SUMMARY - echo "| Security Audit | $(status '${{ needs.security.result }}') |" >> $GITHUB_STEP_SUMMARY + echo "| Advanced Security | $(status '${{ needs.advanced-security.result }}') |" >> $GITHUB_STEP_SUMMARY # πŸ”Ή BUILD echo "" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 2244624dac..1928076dc4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,9 +21,11 @@ node_modules/ !.yarn/releases !.yarn/versions .data/ +.next-playwright/ # testing coverage/ +coverage** # next.js .next/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 82da144d85..156168057e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,15 @@ +# Changelog + +## [Unreleased] + +--- + ## [3.5.3] - 2026-04-05 ### Fixed - **Middleware:** Resolved infinite redirect loop on dashboard for fresh instances when requireLogin is disabled. -# Changelog - -## [Unreleased] - --- ## [3.5.2] β€” 2026-04-05 diff --git a/next.config.mjs b/next.config.mjs index 4308944988..a8589b8cc7 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,9 +1,11 @@ import createNextIntlPlugin from "next-intl/plugin"; const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); +const distDir = process.env.NEXT_DIST_DIR || ".next"; /** @type {import('next').NextConfig} */ const nextConfig = { + distDir, // Turbopack config: redirect native modules to stubs at build time turbopack: { resolveAlias: { diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 24877df54e..4347d7d651 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -109,6 +109,53 @@ export function getCodexResetTime(quota: CodexQuotaSnapshot): number | null { return Math.max(...times); // Use furthest-out reset to avoid premature unblock } +/** + * T03 (Item 3): Compute the minimum-necessary cooldown based on which window + * is actually exhausted. Prevents over-blocking the account: + * + * - If 7d window >= threshold: cooldown until 7d reset (weekly window exhausted) + * - If 5h window >= threshold: cooldown until 5h reset only (short-term limit) + * - Otherwise: 0 (account is healthy, no cooldown needed) + * + * Called after parsing quota headers from a successful/429 response to + * mark the account accordingly without overly long cooldowns. + * + * @param quota - Parsed quota snapshot from response headers + * @param threshold - Fraction (0-1) that triggers cooldown (default: 0.95) + * @returns Cooldown duration in milliseconds (0 = no cooldown needed) + */ +export function getCodexDualWindowCooldownMs( + quota: CodexQuotaSnapshot, + threshold = 0.95 +): { cooldownMs: number; window: "7d" | "5h" | "none" } { + const now = Date.now(); + + // Compute per-window usage ratios (0..1) + const ratio7d = + quota.limit7d > 0 && Number.isFinite(quota.limit7d) ? quota.usage7d / quota.limit7d : 0; + const ratio5h = + quota.limit5h > 0 && Number.isFinite(quota.limit5h) ? quota.usage5h / quota.limit5h : 0; + + // 7d window takes priority β€” if the weekly budget is near-exhausted, + // we must wait until the weekly reset (not just 5h). + if (ratio7d >= threshold && quota.resetAt7d) { + const resetTime = new Date(quota.resetAt7d).getTime(); + if (resetTime > now) { + return { cooldownMs: resetTime - now, window: "7d" }; + } + } + + // 5h window (primary short-term rate limit) + if (ratio5h >= threshold && quota.resetAt5h) { + const resetTime = new Date(quota.resetAt5h).getTime(); + if (resetTime > now) { + return { cooldownMs: resetTime - now, window: "5h" }; + } + } + + return { cooldownMs: 0, window: "none" }; +} + // Ordered list of effort levels from lowest to highest const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const; type EffortLevel = (typeof EFFORT_ORDER)[number]; diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 68202d9b0a..63322e973f 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -112,9 +112,9 @@ export class DefaultExecutor extends BaseExecutor { if (stream) headers["Accept"] = "text/event-stream"; - // Qwen header cleanup: Remove X-Dashscope-* headers since Qwen uses an OpenAI-compatible endpoint - // (e.g. portal.qwen.ai) via its DefaultExecutor buildUrl override, which rejects native DashScope headers. - if (this.provider === "qwen") { + // Qwen header cleanup: Remove X-Dashscope-* headers if using an API key (DashScope compatible mode). + // If using OAuth (Qwen Code), we MUST keep them for portal.qwen.ai to accept the request. + if (this.provider === "qwen" && effectiveKey) { for (const key of Object.keys(headers)) { if (key.toLowerCase().startsWith("x-dashscope-")) { delete headers[key]; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index e04afe9904..564f5c66f8 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -64,7 +64,9 @@ import { parseCodexQuotaHeaders, getCodexResetTime, getCodexModelScope, + getCodexDualWindowCooldownMs, } from "../executors/codex.ts"; +import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; import { extractUsageFromResponse } from "./usageExtractor.ts"; import { @@ -107,6 +109,7 @@ import { import { resolveStreamFlag, stripMarkdownCodeFence } from "../utils/aiSdkCompat.ts"; import { generateRequestId } from "@/shared/utils/requestId"; import { normalizePayloadForLog } from "@/lib/logPayloads"; +import { extractFacts } from "@/lib/memory/extraction"; import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection"; import { retrieveMemories } from "@/lib/memory/retrieval"; import { @@ -114,12 +117,42 @@ import { getMemorySettings, toMemoryRetrievalConfig, } from "@/lib/memory/settings"; +import { injectSkills } from "@/lib/skills/injection"; +import { handleToolCallExecution } from "@/lib/skills/interception"; import { buildClaudeCodeCompatibleRequest, isClaudeCodeCompatibleProvider, resolveClaudeCodeCompatibleSessionId, } from "../services/claudeCodeCompatible.ts"; +function extractMemoryTextFromResponse( + response: Record | null | undefined +): string { + if (!response || typeof response !== "object") return ""; + + const openAIText = response?.choices?.[0]?.message?.content; + if (typeof openAIText === "string") { + return openAIText.trim(); + } + + if (Array.isArray(response?.content)) { + const contentText = response.content + .filter( + (part: Record) => part?.type === "text" && typeof part?.text === "string" + ) + .map((part: Record) => String(part.text).trim()) + .filter(Boolean) + .join("\n"); + if (contentText) return contentText; + } + + if (typeof response?.output_text === "string") { + return response.output_text.trim(); + } + + return ""; +} + export function shouldUseNativeCodexPassthrough({ provider, sourceFormat, @@ -182,6 +215,53 @@ function restoreClaudePassthroughToolNames( }; } +function materializeDeduplicatedExecutionResult>(result: T): T { + const snapshot = + result && typeof result === "object" + ? ((result as Record)._dedupSnapshot as + | { + status: number; + statusText: string; + headers: [string, string][]; + payload: string; + } + | undefined) + : undefined; + + if (!snapshot) return result; + + return { + ...result, + response: new Response(snapshot.payload, { + status: snapshot.status, + statusText: snapshot.statusText, + headers: snapshot.headers, + }), + } as T; +} + +function getSkillsProviderForFormat(format: string): "openai" | "anthropic" | "google" | "other" { + switch (format) { + case FORMATS.CLAUDE: + return "anthropic"; + case FORMATS.GEMINI: + return "google"; + default: + return "openai"; + } +} + +function getSkillsModelIdForFormat(format: string): string { + switch (format) { + case FORMATS.CLAUDE: + return "claude"; + case FORMATS.GEMINI: + return "gemini"; + default: + return "openai"; + } +} + function getHeaderValueCaseInsensitive( headers: Record | null | undefined, targetName: string @@ -440,10 +520,11 @@ export async function handleChatCore({ }; // T03/T09: on 429, persist exact reset time per scope to avoid global over-blocking. + // Item 3: Use dual-window cooldown to distinguish 5h vs 7d exhaustion. if (status === 429) { - const resetTimeMs = getCodexResetTime(quota); - if (resetTimeMs && resetTimeMs > Date.now()) { - const scopeUntil = new Date(resetTimeMs).toISOString(); + const { cooldownMs, window: exhaustedWindow } = getCodexDualWindowCooldownMs(quota); + if (cooldownMs > 0) { + const scopeUntil = new Date(Date.now() + cooldownMs).toISOString(); const scopeMapRaw = existingProviderData && typeof existingProviderData === "object" && @@ -456,6 +537,17 @@ export async function handleChatCore({ ...(scopeMapRaw as Record), [scope]: scopeUntil, }; + nextProviderData.codexExhaustedWindow = exhaustedWindow; + log?.debug?.( + "CODEX", + `Quota exhaustion on ${exhaustedWindow} window, cooldown until ${scopeUntil}` + ); + } + + // Invalidate the preflight cache for this connection so the next + // isModelAvailable check fetches fresh quota data. + if (connectionId) { + invalidateCodexQuotaCache(connectionId); } } @@ -563,6 +655,7 @@ export async function handleChatCore({ const targetFormat = modelTargetFormat || getTargetFormat(provider); const noLogEnabled = apiKeyInfo?.noLog === true; const detailedLoggingEnabled = !noLogEnabled && (await isDetailedLoggingEnabled()); + const skillRequestId = generateRequestId(); const persistAttemptLogs = ({ status, tokens, @@ -716,6 +809,14 @@ export async function handleChatCore({ log?.debug?.("FORMAT", `${sourceFormat} β†’ ${targetFormat} | stream=${stream}`); // ── Common input sanitization (runs for ALL paths including passthrough) ── + // #994: Normalize max_output_tokens to max_tokens for universal compatibility + if (body.max_output_tokens !== undefined) { + if (body.max_tokens === undefined) { + body.max_tokens = body.max_output_tokens; + } + delete body.max_output_tokens; + } + // #291: Strip empty name fields from messages/input items // Upstream providers (OpenAI, Codex) reject name:"" with 400 errors. if (Array.isArray(body.messages)) { @@ -780,6 +881,23 @@ export async function handleChatCore({ } } + if (apiKeyInfo?.id && memorySettings?.skillsEnabled) { + const existingTools = Array.isArray(body.tools) ? body.tools : []; + const mergedTools = injectSkills({ + provider: getSkillsProviderForFormat(sourceFormat), + existingTools, + apiKeyId: apiKeyInfo.id, + }); + + if (mergedTools.length > existingTools.length) { + body = { + ...body, + tools: mergedTools, + }; + log?.debug?.("SKILLS", `Injected ${mergedTools.length - existingTools.length} skills`); + } + } + // Translate request (pass reqLogger for intermediate logging) let translatedBody = body; const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE; @@ -933,22 +1051,33 @@ export async function handleChatCore({ if (msg.role === "user" && Array.isArray(msg.content)) { msg.content = (msg.content as Record[]).flatMap( (block: Record) => { - if (block.type === "text" || block.type === "image_url" || block.type === "image") { - return [block]; - } - // file / document β†’ extract text content - if (block.type === "file" || block.type === "document") { - const fileContent = - (block.file as Record)?.content ?? - (block.file as Record)?.text ?? - block.content ?? - block.text; - const fileName = - (block.file as Record)?.name ?? block.name ?? "attachment"; - if (typeof fileContent === "string" && fileContent.length > 0) { - return [{ type: "text", text: `[${fileName}]\n${fileContent}` }]; + if ( + block.type === "text" || + block.type === "image_url" || + block.type === "image" || + block.type === "file_url" || + block.type === "file" || + block.type === "document" + ) { + // Only extract text if it's explicitly a text-only representation without data + const fileData = (block.file_url ?? block.file ?? block.document) as any; + if ( + (block.type === "file" || block.type === "document") && + !fileData?.url && + !fileData?.data + ) { + const fileContent = + (block.file as Record)?.content ?? + (block.file as Record)?.text ?? + block.content ?? + block.text; + const fileName = + (block.file as Record)?.name ?? block.name ?? "attachment"; + if (typeof fileContent === "string" && fileContent.length > 0) { + return [{ type: "text", text: `[${fileName}]\n${fileContent}` }]; + } } - return []; + return [block]; } // (#527) tool_result β†’ convert to text instead of dropping. // When Claude Code + superpowers routes through Codex, it sends tool_result @@ -1238,6 +1367,12 @@ export async function handleChatCore({ return { ...rawResult, response: new Response(payload, { status, statusText, headers }), + _dedupSnapshot: { + status, + statusText, + headers, + payload, + }, }; }; @@ -1246,7 +1381,7 @@ export async function handleChatCore({ if (dedupResult.wasDeduplicated) { log?.debug?.("DEDUP", `Joined in-flight request hash=${dedupHash}`); } - return dedupResult.result; + return materializeDeduplicatedExecutionResult(dedupResult.result); } return execute(); @@ -1302,11 +1437,16 @@ export async function handleChatCore({ ); } catch (error) { trackPendingRequest(model, provider, connectionId, false); - const failureStatus = error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY; + const failureStatus = + error.name === "AbortError" + ? 499 + : error.name === "TimeoutError" + ? HTTP_STATUS.GATEWAY_TIMEOUT + : HTTP_STATUS.BAD_GATEWAY; const failureMessage = error.name === "AbortError" ? "Request aborted" - : formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); + : formatProviderError(error, provider, model, failureStatus); appendRequestLog({ model, provider, @@ -1325,11 +1465,11 @@ export async function handleChatCore({ return createErrorResult(499, "Request aborted"); } persistFailureUsage( - HTTP_STATUS.BAD_GATEWAY, + failureStatus, error instanceof Error && error.name ? error.name : "upstream_error" ); console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`); - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, failureMessage); + return createErrorResult(failureStatus, failureMessage); } // We need to peek at the error text if it's 400 for Qwen let upstreamErrorParsed = false; @@ -2029,6 +2169,35 @@ export async function handleChatCore({ } } + const pipelineSessionId = + (clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function" + ? clientRawRequest.headers.get("x-omniroute-session-id") + : getHeaderValueCaseInsensitive( + clientRawRequest?.headers ?? null, + "x-omniroute-session-id" + )) || skillRequestId; + + if (apiKeyInfo?.id && memorySettings?.enabled && memorySettings.maxTokens > 0) { + const memoryText = extractMemoryTextFromResponse(translatedResponse); + if (memoryText) { + extractFacts(memoryText, apiKeyInfo.id, pipelineSessionId); + } + } + + if (apiKeyInfo?.id && memorySettings?.skillsEnabled) { + const skillSessionId = pipelineSessionId; + + translatedResponse = await handleToolCallExecution( + translatedResponse, + getSkillsModelIdForFormat(sourceFormat), + { + apiKeyId: apiKeyInfo.id, + sessionId: skillSessionId, + requestId: skillRequestId, + } + ); + } + // ── Phase 9.1: Cache store (non-streaming, temp=0) ── if (isCacheable(body, clientRawRequest?.headers)) { const signature = generateSignature(model, body.messages, body.temperature, body.top_p); diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 1622653c69..2792245015 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -226,85 +226,135 @@ export function translateNonStreamingResponse( const root = toRecord(responseBody); const response = toRecord(root.response ?? root); const candidates = Array.isArray(response.candidates) ? response.candidates : []; - if (candidates[0]) { - const candidate = toRecord(candidates[0]); - const content = toRecord(candidate.content); - const usage = toRecord(response.usageMetadata ?? root.usageMetadata); - - let textContent = ""; - const toolCalls: JsonRecord[] = []; - let reasoningContent = ""; - - if (Array.isArray(content.parts)) { - for (const part of content.parts) { - const partObj = toRecord(part); - if (partObj.thought === true && typeof partObj.text === "string") { - reasoningContent += partObj.text; - } else if (typeof partObj.text === "string") { - textContent += partObj.text; - } - if (partObj.functionCall) { - const fn = toRecord(partObj.functionCall); - toolCalls.push({ - id: `call_${toString(fn.name, "unknown")}_${Date.now()}_${toolCalls.length}`, - type: "function", - function: { - name: toString(fn.name), - arguments: JSON.stringify(fn.args || {}), - }, - }); - } - } - } - - const message: JsonRecord = { role: "assistant" }; - if (textContent) { - message.content = textContent; - } - if (reasoningContent) { - message.reasoning_content = reasoningContent; - } - if (toolCalls.length > 0) { - message.tool_calls = toolCalls; - } - if (!message.content && !message.tool_calls) { - message.content = ""; - } - - let finishReason = toString(candidate.finishReason, "stop").toLowerCase(); - if (finishReason === "stop" && toolCalls.length > 0) { - finishReason = "tool_calls"; - } - + const usage = toRecord(response.usageMetadata ?? root.usageMetadata); + const promptFeedback = toRecord(response.promptFeedback ?? root.promptFeedback); + if (candidates.length > 0 || Object.keys(promptFeedback).length > 0) { const createdMs = Date.parse(toString(response.createTime)); const created = Number.isFinite(createdMs) ? Math.floor(createdMs / 1000) : Math.floor(Date.now() / 1000); + const choices = + candidates.length > 0 + ? candidates.map((candidateValue, index) => { + const candidate = toRecord(candidateValue); + const content = toRecord(candidate.content); + + let textContent = ""; + const contentParts: JsonRecord[] = []; + const toolCalls: JsonRecord[] = []; + let reasoningContent = ""; + + if (Array.isArray(content.parts)) { + for (const part of content.parts) { + const partObj = toRecord(part); + if (partObj.thought === true && typeof partObj.text === "string") { + reasoningContent += partObj.text; + continue; + } + + if (typeof partObj.text === "string") { + textContent += partObj.text; + contentParts.push({ type: "text", text: partObj.text }); + } + + const inlineData = toRecord(partObj.inlineData ?? partObj.inline_data); + if (typeof inlineData.data === "string" && inlineData.data.length > 0) { + const mimeType = toString( + inlineData.mimeType ?? inlineData.mime_type, + "image/png" + ); + contentParts.push({ + type: "image_url", + image_url: { url: `data:${mimeType};base64,${inlineData.data}` }, + }); + } + + if (partObj.functionCall) { + const fn = toRecord(partObj.functionCall); + toolCalls.push({ + id: `call_${toString(fn.name, "unknown")}_${Date.now()}_${toolCalls.length}`, + type: "function", + function: { + name: toString(fn.name), + arguments: JSON.stringify(fn.args || {}), + }, + }); + } + } + } + + const message: JsonRecord = { role: "assistant" }; + if (contentParts.length === 1 && contentParts[0].type === "text") { + message.content = contentParts[0].text; + } else if (contentParts.length > 0) { + message.content = contentParts; + } else if (textContent) { + message.content = textContent; + } + if (reasoningContent) { + message.reasoning_content = reasoningContent; + } + if (toolCalls.length > 0) { + message.tool_calls = toolCalls; + } + if (!message.content && !message.tool_calls) { + message.content = ""; + } + + let finishReason = toString(candidate.finishReason, "stop").toLowerCase(); + if (finishReason === "max_tokens") { + finishReason = "length"; + } else if ( + finishReason === "safety" || + finishReason === "recitation" || + finishReason === "blocklist" + ) { + finishReason = "content_filter"; + } else if (finishReason === "stop" && toolCalls.length > 0) { + finishReason = "tool_calls"; + } + + return { + index, + message, + finish_reason: finishReason, + }; + }) + : [ + { + index: 0, + message: { role: "assistant", content: "" }, + finish_reason: "content_filter", + }, + ]; + const result: JsonRecord = { id: `chatcmpl-${toString(response.responseId, String(Date.now()))}`, object: "chat.completion", created, model: toString(response.modelVersion, "gemini"), - choices: [ - { - index: 0, - message, - finish_reason: finishReason, - }, - ], + choices, }; if (Object.keys(usage).length > 0) { + const promptTokens = toNumber(usage.promptTokenCount, 0); + const reasoningTokens = toNumber(usage.thoughtsTokenCount, 0); + const completionTokens = toNumber(usage.candidatesTokenCount, 0) + reasoningTokens; + result.usage = { - prompt_tokens: - toNumber(usage.promptTokenCount, 0) + toNumber(usage.thoughtsTokenCount, 0), - completion_tokens: toNumber(usage.candidatesTokenCount, 0), + prompt_tokens: promptTokens, + completion_tokens: completionTokens, total_tokens: toNumber(usage.totalTokenCount, 0), }; - if (toNumber(usage.thoughtsTokenCount, 0) > 0) { + if (reasoningTokens > 0) { (result.usage as JsonRecord).completion_tokens_details = { - reasoning_tokens: toNumber(usage.thoughtsTokenCount, 0), + reasoning_tokens: reasoningTokens, + }; + } + if (toNumber(usage.cachedContentTokenCount, 0) > 0) { + (result.usage as JsonRecord).prompt_tokens_details = { + cached_tokens: toNumber(usage.cachedContentTokenCount, 0), }; } } diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts index eed7c43cdb..52c2a1c758 100644 --- a/open-sse/handlers/usageExtractor.ts +++ b/open-sse/handlers/usageExtractor.ts @@ -4,6 +4,11 @@ */ export function extractUsageFromResponse(responseBody, provider) { if (!responseBody || typeof responseBody !== "object") return null; + const providerId = typeof provider === "string" ? provider.toLowerCase() : ""; + const isClaudeProvider = + providerId === "claude" || + providerId === "anthropic" || + providerId.startsWith("anthropic-compatible"); // OpenAI format (has prompt_tokens / completion_tokens) if ( @@ -19,6 +24,29 @@ export function extractUsageFromResponse(responseBody, provider) { }; } + // Claude format + if ( + isClaudeProvider && + responseBody.usage && + typeof responseBody.usage === "object" && + (responseBody.usage.input_tokens !== undefined || + responseBody.usage.output_tokens !== undefined) + ) { + const inputTokens = responseBody.usage.input_tokens || 0; + const cacheRead = responseBody.usage.cache_read_input_tokens || 0; + const cacheCreation = responseBody.usage.cache_creation_input_tokens || 0; + + // Total prompt tokens = input + cache_read + cache_creation (per Claude API docs) + const promptTokens = inputTokens + cacheRead + cacheCreation; + + return { + prompt_tokens: promptTokens, + completion_tokens: responseBody.usage.output_tokens || 0, + cache_read_input_tokens: cacheRead, + cache_creation_input_tokens: cacheCreation, + }; + } + // OpenAI Responses API format (input_tokens / output_tokens) const responsesUsage = responseBody.response?.usage || responseBody.usage; if ( @@ -39,28 +67,6 @@ export function extractUsageFromResponse(responseBody, provider) { }; } - // Claude format - if ( - responseBody.usage && - typeof responseBody.usage === "object" && - (responseBody.usage.input_tokens !== undefined || - responseBody.usage.output_tokens !== undefined) - ) { - const inputTokens = responseBody.usage.input_tokens || 0; - const cacheRead = responseBody.usage.cache_read_input_tokens || 0; - const cacheCreation = responseBody.usage.cache_creation_input_tokens || 0; - - // Total prompt tokens = input + cache_read + cache_creation (per Claude API docs) - const promptTokens = inputTokens + cacheRead + cacheCreation; - - return { - prompt_tokens: promptTokens, - completion_tokens: responseBody.usage.output_tokens || 0, - cache_read_input_tokens: cacheRead, - cache_creation_input_tokens: cacheCreation, - }; - } - // Gemini format if (responseBody.usageMetadata && typeof responseBody.usageMetadata === "object") { return { diff --git a/open-sse/mcp-server/tools/memoryTools.ts b/open-sse/mcp-server/tools/memoryTools.ts index a30c6a13a3..4863e9d758 100644 --- a/open-sse/mcp-server/tools/memoryTools.ts +++ b/open-sse/mcp-server/tools/memoryTools.ts @@ -40,6 +40,7 @@ export const memoryTools = { persistAcrossModels: false, retentionDays: 30, scope: "apiKey" as const, + query: args.query, }; const memories = await retrieveMemories(args.apiKeyId, config); diff --git a/open-sse/services/codexQuotaFetcher.ts b/open-sse/services/codexQuotaFetcher.ts new file mode 100644 index 0000000000..e28c0b1d61 --- /dev/null +++ b/open-sse/services/codexQuotaFetcher.ts @@ -0,0 +1,266 @@ +/** + * codexQuotaFetcher.ts β€” Codex Dual-Window Quota Fetcher + * + * Implements QuotaFetcher for the Codex provider (quotaPreflight.ts + quotaMonitor.ts). + * + * Codex has TWO independent quota windows: + * - Primary (5h): short-term rate limit, resets every 5 hours + * - Secondary (7d): weekly limit, resets every 7 days + * + * We return percentUsed = max(5h%, 7d%) so the system switches accounts when + * EITHER window approaches exhaustion (95% threshold). + * + * Cache: in-memory TTL (60s) to avoid hammering the usage API on every request. + * The connection pool is keyed by connectionId (providerConnection.id from DB). + * + * Registration: call registerCodexQuotaFetcher() once at server startup. + */ + +import { registerQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts"; +import { registerMonitorFetcher } from "./quotaMonitor.ts"; + +// Codex usage endpoint (same as usage.ts CODEX_CONFIG) +const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; + +// Cache TTL β€” short enough to be reactive, long enough to avoid rate limits +const CACHE_TTL_MS = 60_000; // 60 seconds + +// Per-account quota window info (richer than QuotaInfo β€” includes both windows) +export interface CodexDualWindowQuota extends QuotaInfo { + window5h: { percentUsed: number; resetAt: string | null }; + window7d: { percentUsed: number; resetAt: string | null }; + limitReached: boolean; +} + +interface CacheEntry { + quota: CodexDualWindowQuota; + fetchedAt: number; +} + +// In-memory cache: connectionId β†’ { quota, fetchedAt } +const quotaCache = new Map(); + +// Auto-cleanup stale entries every 5 minutes +const _cacheCleanup = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of quotaCache) { + if (now - entry.fetchedAt > CACHE_TTL_MS * 5) { + quotaCache.delete(key); + } + } +}, 5 * 60_000); + +if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) { + (_cacheCleanup as { unref?: () => void }).unref?.(); +} + +// ─── Connection registry ───────────────────────────────────────────────────── +// We need the accessToken + workspaceId to call the API. +// chatCore.ts registers connection metadata here before requests. + +interface CodexConnectionMeta { + accessToken: string; + workspaceId?: string; +} + +const connectionRegistry = new Map(); + +/** + * Register Codex connection metadata for quota fetching. + * Called by chatCore.ts when a Codex connection is resolved. + * + * @param connectionId - The connection ID from the DB (providerConnection.id) + * @param meta - Access token and optional workspace ID + */ +export function registerCodexConnection(connectionId: string, meta: CodexConnectionMeta): void { + connectionRegistry.set(connectionId, meta); +} + +// ─── Core Fetcher ──────────────────────────────────────────────────────────── + +/** + * Fetch current quota for a Codex connection. + * Returns percentUsed = max(5h%, 7d%) β€” worst-case across both windows. + * + * @param connectionId - Connection ID from the DB (used to look up credentials) + * @returns QuotaInfo or null if fetch fails / no credentials + */ +export async function fetchCodexQuota(connectionId: string): Promise { + // Check cache first + const cached = quotaCache.get(connectionId); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.quota; + } + + // Look up credentials + const meta = connectionRegistry.get(connectionId); + if (!meta?.accessToken) { + // No credentials registered β€” skip preflight gracefully + return null; + } + + try { + const headers: Record = { + Authorization: `Bearer ${meta.accessToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }; + + if (meta.workspaceId) { + headers["chatgpt-account-id"] = meta.workspaceId; + } + + const response = await fetch(CODEX_USAGE_URL, { + method: "GET", + headers, + signal: AbortSignal.timeout(8_000), + }); + + if (!response.ok) { + // Non-2xx: could be token expired or quota API down. + // Return null to proceed (fail-open β€” don't block on API errors). + if (response.status === 401 || response.status === 403) { + // Token expired β€” remove from cache so next call re-fetches + quotaCache.delete(connectionId); + connectionRegistry.delete(connectionId); + } + return null; + } + + const data = await response.json(); + const quota = parseCodexUsageResponse(data); + + if (!quota) return null; + + // Store in cache + quotaCache.set(connectionId, { quota, fetchedAt: Date.now() }); + return quota; + } catch { + // Network error, timeout, etc. β€” fail open + return null; + } +} + +// ─── Response Parser ───────────────────────────────────────────────────────── + +function toNumber(value: unknown, fallback = 0): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = parseFloat(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +function toRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function parseWindowReset(window: Record): string | null { + const resetAt = toNumber(window["reset_at"] ?? window["resetAt"], 0); + if (resetAt > 0) { + return new Date(resetAt * 1000).toISOString(); + } + const resetAfterSeconds = toNumber( + window["reset_after_seconds"] ?? window["resetAfterSeconds"], + 0 + ); + if (resetAfterSeconds > 0) { + return new Date(Date.now() + resetAfterSeconds * 1000).toISOString(); + } + return null; +} + +function parseCodexUsageResponse(data: unknown): CodexDualWindowQuota | null { + const obj = toRecord(data); + const rateLimit = toRecord(obj["rate_limit"] ?? obj["rateLimit"]); + const primaryWindow = toRecord(rateLimit["primary_window"] ?? rateLimit["primaryWindow"]); + const secondaryWindow = toRecord(rateLimit["secondary_window"] ?? rateLimit["secondaryWindow"]); + + // Require at least one window to be present + const hasPrimary = Object.keys(primaryWindow).length > 0; + const hasSecondary = Object.keys(secondaryWindow).length > 0; + if (!hasPrimary && !hasSecondary) return null; + + // Parse 5h window + const usedPercent5h = hasPrimary + ? toNumber(primaryWindow["used_percent"] ?? primaryWindow["usedPercent"], 0) + : 0; + const resetAt5h = hasPrimary ? parseWindowReset(primaryWindow) : null; + + // Parse 7d window + const usedPercent7d = hasSecondary + ? toNumber(secondaryWindow["used_percent"] ?? secondaryWindow["usedPercent"], 0) + : 0; + const resetAt7d = hasSecondary ? parseWindowReset(secondaryWindow) : null; + + // Worst-case across both windows (triggers switch when EITHER is at 95%) + const worstPercentUsed = Math.max(usedPercent5h, usedPercent7d); + const percentUsedNormalized = worstPercentUsed / 100; // QuotaInfo uses 0..1 + + const limitReached = Boolean(rateLimit["limit_reached"] ?? rateLimit["limitReached"]); + + return { + used: worstPercentUsed, + total: 100, + percentUsed: percentUsedNormalized, + window5h: { percentUsed: usedPercent5h / 100, resetAt: resetAt5h }, + window7d: { percentUsed: usedPercent7d / 100, resetAt: resetAt7d }, + limitReached, + }; +} + +// ─── Quota-Aware Reset Time ─────────────────────────────────────────────────── + +/** + * Get the cooldown duration (ms) for a Codex account based on its quota state. + * + * Logic: + * - If 7d window >= threshold β†’ cooldown until 7d reset (longer) + * - If 5h window >= threshold β†’ cooldown until 5h reset (shorter) + * - Otherwise β†’ 0 (no cooldown) + * + * @param quota - The dual-window quota snapshot + * @param threshold - The fraction (0-1) that triggers a switch (default: 0.95) + * @returns Cooldown duration in milliseconds + */ +export function getCodexQuotaCooldownMs(quota: CodexDualWindowQuota, threshold = 0.95): number { + const now = Date.now(); + + // 7d window takes priority (if exhausted, must wait longer) + if (quota.window7d.percentUsed >= threshold && quota.window7d.resetAt) { + const resetTime = new Date(quota.window7d.resetAt).getTime(); + if (resetTime > now) return resetTime - now; + } + + // 5h window + if (quota.window5h.percentUsed >= threshold && quota.window5h.resetAt) { + const resetTime = new Date(quota.window5h.resetAt).getTime(); + if (resetTime > now) return resetTime - now; + } + + return 0; +} + +// ─── Invalidation ──────────────────────────────────────────────────────────── + +/** + * Force-invalidate the cache for a connection (e.g., after receiving quota headers). + * Ensures the next preflight call fetches fresh data. + */ +export function invalidateCodexQuotaCache(connectionId: string): void { + quotaCache.delete(connectionId); +} + +// ─── Registration ───────────────────────────────────────────────────────────── + +/** + * Register the Codex quota fetcher with the preflight and monitor systems. + * Call this once at server startup (in chatCore.ts or app entry point). + */ +export function registerCodexQuotaFetcher(): void { + registerQuotaFetcher("codex", fetchCodexQuota); + registerMonitorFetcher("codex", fetchCodexQuota); +} diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index a39bd3f4e8..e7a93791e9 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -961,6 +961,7 @@ interface RefreshLoggerLike { export function isProviderBlocked(provider: string): boolean { const state = _circuitBreaker[provider]; if (!state) return false; + if (!state.blockedUntil) return false; if (state.blockedUntil > Date.now()) return true; // Cooldown expired β€” reset delete _circuitBreaker[provider]; @@ -1016,10 +1017,23 @@ function recordFailure(provider: string, log: RefreshLoggerLike | null = null) { * Execute a function with a timeout. */ async function withTimeout(fn: () => Promise, timeoutMs: number): Promise { - return Promise.race([ - fn(), - new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)), - ]); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(null), timeoutMs); + if (typeof timer === "object" && "unref" in timer) { + (timer as { unref?: () => void }).unref?.(); + } + + fn().then( + (result) => { + clearTimeout(timer); + resolve(result); + }, + (error) => { + clearTimeout(timer); + reject(error); + } + ); + }); } export async function refreshWithRetry( diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index dafdfd3a1b..312e90ae7c 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -75,17 +75,20 @@ export function convertOpenAIContentToParts(content) { for (const item of content) { if (item.type === "text") { parts.push({ text: item.text }); - } else if (item.type === "image_url" && item.image_url?.url?.startsWith("data:")) { - const url = item.image_url.url; - const commaIndex = url.indexOf(","); - if (commaIndex !== -1) { - const mimePart = url.substring(5, commaIndex); // skip "data:" - const data = url.substring(commaIndex + 1); - const mimeType = mimePart.split(";")[0]; + } else { + const fileData = + item.image_url?.url || item.file_url?.url || item.file?.url || item.document?.url; + if (typeof fileData === "string" && fileData.startsWith("data:")) { + const commaIndex = fileData.indexOf(","); + if (commaIndex !== -1) { + const mimePart = fileData.substring(5, commaIndex); // skip "data:" + const data = fileData.substring(commaIndex + 1); + const mimeType = mimePart.split(";")[0]; - parts.push({ - inlineData: { mimeType, data }, - }); + parts.push({ + inlineData: { mimeType, data }, + }); + } } } } diff --git a/open-sse/translator/helpers/maxTokensHelper.ts b/open-sse/translator/helpers/maxTokensHelper.ts index b9d833e82b..af6b7c24e8 100644 --- a/open-sse/translator/helpers/maxTokensHelper.ts +++ b/open-sse/translator/helpers/maxTokensHelper.ts @@ -6,7 +6,8 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/constants.t * @returns {number} Adjusted max_tokens */ export function adjustMaxTokens(body) { - let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS; + const requestedMaxTokens = body.max_tokens ?? body.max_completion_tokens; + let maxTokens = requestedMaxTokens || DEFAULT_MAX_TOKENS; // Auto-increase for tool calling to prevent truncated arguments // Tool calls with large content (like writing files) need more tokens diff --git a/open-sse/translator/helpers/openaiHelper.ts b/open-sse/translator/helpers/openaiHelper.ts index 125d1288a9..f3ae1c1ee4 100644 --- a/open-sse/translator/helpers/openaiHelper.ts +++ b/open-sse/translator/helpers/openaiHelper.ts @@ -1,11 +1,22 @@ // OpenAI helper functions for translator // Valid OpenAI content block types -export const VALID_OPENAI_CONTENT_TYPES = ["text", "image_url", "image"]; +export const VALID_OPENAI_CONTENT_TYPES = [ + "text", + "image_url", + "image", + "file_url", + "file", + "document", +]; export const VALID_OPENAI_MESSAGE_TYPES = [ "text", "image_url", "image", + "file_url", + "file", + "document", + "image", "tool_calls", "tool_result", ]; diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index 72d9821ad3..47c47a975f 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -27,6 +27,12 @@ export function claudeToOpenAIRequest(model, body, stream) { if (body.temperature !== undefined) { result.temperature = body.temperature; } + if (body.top_p !== undefined) { + result.top_p = body.top_p; + } + if (body.stop_sequences !== undefined) { + result.stop = body.stop_sequences; + } // System message if (body.system) { @@ -149,6 +155,7 @@ function convertClaudeMessage(msg) { const parts = []; const toolCalls = []; const toolResults = []; + let reasoningContent = null; for (const block of msg.content) { switch (block.type) { @@ -164,6 +171,23 @@ function convertClaudeMessage(msg) { url: `data:${block.source.media_type};base64,${block.source.data}`, }, }); + } else if (block.source?.type === "url" && typeof block.source.url === "string") { + parts.push({ + type: "image_url", + image_url: { + url: block.source.url, + }, + }); + } + break; + + case "thinking": + reasoningContent = block.thinking || block.text || ""; + break; + + case "redacted_thinking": + if (reasoningContent == null) { + reasoningContent = ""; } break; @@ -217,20 +241,35 @@ function convertClaudeMessage(msg) { result.content = parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts; } result.tool_calls = toolCalls; + if (reasoningContent !== null) { + result.reasoning_content = reasoningContent; + } return result; } // Return content if (parts.length > 0) { - return { + const result: JsonRecord = { role, content: parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts, }; + if (reasoningContent !== null && role === "assistant") { + result.reasoning_content = reasoningContent; + } + return result; } // Empty content array if (msg.content.length === 0) { - return { role, content: "" }; + const result: JsonRecord = { role, content: "" }; + if (reasoningContent !== null && role === "assistant") { + result.reasoning_content = reasoningContent; + } + return result; + } + + if (reasoningContent !== null && role === "assistant") { + return { role, content: "", reasoning_content: reasoningContent }; } } diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 4eb6521c54..1d20eac08a 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -111,6 +111,12 @@ export function openaiToClaudeRequest(model, body, stream) { if (body.temperature !== undefined) { result.temperature = body.temperature; } + if (body.top_p !== undefined) { + result.top_p = body.top_p; + } + if (body.stop !== undefined) { + result.stop_sequences = Array.isArray(body.stop) ? body.stop : [body.stop]; + } // Messages const systemParts = []; @@ -232,49 +238,42 @@ export function openaiToClaudeRequest(model, body, stream) { } } - // System with Claude Code prompt and cache_control - const claudeCodePrompt = { type: "text", text: CLAUDE_SYSTEM_PROMPT }; - - if (systemParts.length > 0) { - const systemText = systemParts.join("\n"); - result.system = [ - claudeCodePrompt, - { type: "text", text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } }, - ]; - } else { - result.system = [claudeCodePrompt]; - } - // Tools - convert from OpenAI format to Claude format with prefix for OAuth if (body.tools && Array.isArray(body.tools)) { - result.tools = body.tools.map((tool) => { - const toolData = tool.type === "function" && tool.function ? tool.function : tool; - const originalName = toolData.name; + result.tools = body.tools + .map((tool) => { + const toolData = tool.type === "function" && tool.function ? tool.function : tool; + const originalName = typeof toolData.name === "string" ? toolData.name.trim() : ""; - // Claude OAuth requires prefixed tool names to avoid conflicts - // When prefix is disabled (non-Claude backends), use original name - const toolName = disableToolPrefix ? originalName : CLAUDE_OAUTH_TOOL_PREFIX + originalName; + if (!originalName) { + return null; + } - // Store mapping for response translation (prefixed β†’ original) - if (!disableToolPrefix) { - toolNameMap.set(toolName, originalName); - } + // Claude OAuth requires prefixed tool names to avoid conflicts + // When prefix is disabled (non-Claude backends), use original name + const toolName = disableToolPrefix ? originalName : CLAUDE_OAUTH_TOOL_PREFIX + originalName; - // Normalize input_schema: Anthropic requires `properties` when type is "object" (#595). - // MCP tools (e.g. pencil, computer_use) may omit properties on object-type schemas. - const rawSchema: Record = toolData.parameters || - toolData.input_schema || { type: "object", properties: {}, required: [] }; - const normalizedSchema = - rawSchema.type === "object" && !rawSchema.properties - ? { ...rawSchema, properties: {} } - : rawSchema; + // Store mapping for response translation (prefixed β†’ original) + if (!disableToolPrefix) { + toolNameMap.set(toolName, originalName); + } - return { - name: toolName, - description: toolData.description || "", - input_schema: normalizedSchema, - }; - }); + // Normalize input_schema: Anthropic requires `properties` when type is "object" (#595). + // MCP tools (e.g. pencil, computer_use) may omit properties on object-type schemas. + const rawSchema: Record = toolData.parameters || + toolData.input_schema || { type: "object", properties: {}, required: [] }; + const normalizedSchema = + rawSchema.type === "object" && !rawSchema.properties + ? { ...rawSchema, properties: {} } + : rawSchema; + + return { + name: toolName, + description: toolData.description || "", + input_schema: normalizedSchema, + }; + }) + .filter((tool): tool is ClaudeTool => Boolean(tool)); // Filter out tools with empty names (would cause Claude 400 error) result.tools = result.tools.filter((tool) => tool.name && tool.name?.trim()); @@ -311,6 +310,19 @@ export function openaiToClaudeRequest(model, body, stream) { } } + // System with Claude Code prompt and cache_control + const claudeCodePrompt = { type: "text", text: CLAUDE_SYSTEM_PROMPT }; + + if (systemParts.length > 0) { + const systemText = systemParts.join("\n"); + result.system = [ + claudeCodePrompt, + { type: "text", text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } }, + ]; + } else { + result.system = [claudeCodePrompt]; + } + // Thinking configuration if (body.thinking) { result.thinking = { @@ -399,6 +411,11 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr type: "image", source: { type: "base64", media_type: match[1], data: match[2] }, }); + } else if (typeof url === "string" && url.trim()) { + blocks.push({ + type: "image", + source: { type: "url", url }, + }); } } else if (part.type === "image" && part.source) { blocks.push({ type: "image", source: part.source }); diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 96431330dc..d03ac45a50 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -90,7 +90,7 @@ function openaiToGeminiBase(model, body, stream) { model: model, contents: [], generationConfig: {}, - safetySettings: DEFAULT_SAFETY_SETTINGS, + safetySettings: body.safetySettings || DEFAULT_SAFETY_SETTINGS, }; // Preserve cachedContent if provided by client (for explicit Gemini caching) @@ -108,8 +108,12 @@ function openaiToGeminiBase(model, body, stream) { if (body.top_k !== undefined) { result.generationConfig.topK = body.top_k; } - if (body.max_tokens !== undefined) { - result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, body.max_tokens); + if (body.stop !== undefined) { + result.generationConfig.stopSequences = Array.isArray(body.stop) ? body.stop : [body.stop]; + } + const requestedMaxOutputTokens = body.max_tokens ?? body.max_completion_tokens; + if (requestedMaxOutputTokens !== undefined) { + result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, requestedMaxOutputTokens); } else { result.generationConfig.maxOutputTokens = capMaxOutputTokens(model); } @@ -146,10 +150,17 @@ function openaiToGeminiBase(model, body, stream) { const content = msg.content; if (role === "system" && body.messages.length > 1) { - result.systemInstruction = { - role: "user", - parts: [{ text: typeof content === "string" ? content : extractTextContent(content) }], - }; + const systemText = typeof content === "string" ? content : extractTextContent(content); + if (systemText) { + if (!result.systemInstruction) { + result.systemInstruction = { + role: "user", + parts: [{ text: systemText }], + }; + } else { + result.systemInstruction.parts.push({ text: systemText }); + } + } } else if (role === "user" || (role === "system" && body.messages.length === 1)) { const parts = convertOpenAIContentToParts(content); if (parts.length > 0) { diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index ba36a5d2ce..d29168f7a8 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -4,7 +4,7 @@ */ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; -import { v4 as uuidv4 } from "uuid"; +import { v4 as uuidv4, v5 as uuidv5 } from "uuid"; /** * Convert OpenAI messages to Kiro format @@ -252,7 +252,7 @@ function convertMessages(messages, tools, model) { export function buildKiroPayload(model, body, stream, credentials) { const messages = body.messages || []; const tools = body.tools || []; - const maxTokens = 32000; + const maxTokens = body.max_tokens ?? body.max_completion_tokens ?? 32000; const temperature = body.temperature; const topP = body.top_p; @@ -310,7 +310,6 @@ export function buildKiroPayload(model, body, stream, credentials) { : finalContent; // Use uuidv5 with the hash of the system prompt / first message to maintain AWS Builder ID context cache - const { v5: uuidv5 } = require("uuid"); payload.conversationState.conversationId = uuidv5( (firstContent || "").substring(0, 4000), NAMESPACE_KIRO diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index e335231dbc..bc6d6bb877 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -8,10 +8,50 @@ export function geminiToOpenAIResponse(chunk, state) { // Handle Antigravity wrapper const response = chunk.response || chunk; - if (!response || !response.candidates?.[0]) return null; + if (!response) return null; const results = []; - const candidate = response.candidates[0]; + const candidate = response.candidates?.[0]; + + if (!candidate) { + const promptFeedback = response.promptFeedback || chunk.promptFeedback; + if (!promptFeedback) return null; + + if (!state.messageId) { + state.messageId = response.responseId || `msg_${Date.now()}`; + state.model = response.modelVersion || "gemini"; + results.push({ + id: `chatcmpl-${state.messageId}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: state.model, + choices: [ + { + index: 0, + delta: { role: "assistant" }, + finish_reason: null, + }, + ], + }); + } + + results.push({ + id: `chatcmpl-${state.messageId}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: state.model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: "content_filter", + }, + ], + }); + + return results; + } + const content = candidate.content; // Initialize state @@ -238,6 +278,8 @@ export function geminiToOpenAIResponse(chunk, state) { let finishReason = candidate.finishReason.toLowerCase(); if (finishReason === "stop" && state.toolCalls.size > 0) { finishReason = "tool_calls"; + } else if (finishReason === "max_tokens") { + finishReason = "length"; } // Content blocked by Gemini safety filters β€” pass through as "content_filter" // so downstream clients can distinguish from normal completion. diff --git a/open-sse/utils/bypassHandler.ts b/open-sse/utils/bypassHandler.ts index 6ddf9c8a9e..657c7e1dff 100644 --- a/open-sse/utils/bypassHandler.ts +++ b/open-sse/utils/bypassHandler.ts @@ -20,7 +20,8 @@ import { formatSSE } from "./stream.ts"; * @returns {object|null} Bypass response or null to proceed normally */ export function handleBypassRequest(body, model, userAgent = "") { - if (!userAgent.includes("claude-cli")) return null; + const normalizedUserAgent = typeof userAgent === "string" ? userAgent : ""; + if (!normalizedUserAgent.includes("claude-cli")) return null; if (!body.messages?.length) return null; const messages = body.messages; diff --git a/package-lock.json b/package-lock.json index 2b1333ac53..84526bc4d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.5.2", + "version": "3.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.5.2", + "version": "3.5.3", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 16852c8502..a6a2d30f51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.5.2", + "version": "3.5.3", "description": "Smart AI Router with auto fallback β€” route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { @@ -72,10 +72,10 @@ "test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs", "test:vitest": "vitest run --config vitest.mcp.config.ts", "test:ecosystem": "node scripts/run-ecosystem-tests.mjs", - "test:coverage": "c8 --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 55 --lines 55 --functions 55 --branches 60 node --import tsx/esm --test tests/unit/*.test.mjs", - "test:coverage:legacy": "c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs", - "coverage:report": "c8 report --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov", - "coverage:report:legacy": "c8 report --exclude=open-sse --reporter=text --reporter=text-summary", + "test:coverage": "c8 --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 55 --lines 55 --functions 55 --branches 60 node --import tsx/esm --test tests/unit/*.test.mjs", + "test:coverage:legacy": "c8 --output-dir coverage --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs", + "coverage:report": "c8 report --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov", + "coverage:report:legacy": "c8 report --output-dir coverage --exclude=open-sse --reporter=text --reporter=text-summary", "test:all": "npm run test:unit && npm run test:vitest && npm run test:ecosystem && npm run test:e2e", "check": "npm run lint && npm run test", "prepublishOnly": "npm run build:cli", diff --git a/playwright.config.ts b/playwright.config.ts index 15e199a22e..f18cd254e4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,18 +3,20 @@ import { defineConfig, devices } from "@playwright/test"; const dashboardPort = process.env.DASHBOARD_PORT || process.env.PORT || "20128"; const dashboardBaseUrl = `http://localhost:${dashboardPort}`; const webServerReadyUrl = `${dashboardBaseUrl}/api/monitoring/health`; +const playwrightServerMode = process.env.OMNIROUTE_PLAYWRIGHT_SERVER_MODE || "start"; export default defineConfig({ testDir: "./tests/e2e", testMatch: ["**/*.spec.ts"], fullyParallel: false, - timeout: 120_000, + timeout: 600_000, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: 1, reporter: process.env.CI ? "github" : "html", use: { baseURL: dashboardBaseUrl, + navigationTimeout: 300_000, trace: "on-first-retry", screenshot: "only-on-failure", }, @@ -25,11 +27,9 @@ export default defineConfig({ }, ], webServer: { - command: process.env.CI - ? "node scripts/run-next-playwright.mjs start" - : "node scripts/run-next-playwright.mjs dev", + command: `node scripts/run-next-playwright.mjs ${playwrightServerMode}`, url: webServerReadyUrl, reuseExistingServer: !process.env.CI, - timeout: 120_000, + timeout: 300_000, }, }); diff --git a/scripts/run-next-playwright.mjs b/scripts/run-next-playwright.mjs index 073b98d05f..37cfb1bdf5 100644 --- a/scripts/run-next-playwright.mjs +++ b/scripts/run-next-playwright.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { spawn } from "node:child_process"; import { existsSync, renameSync } from "node:fs"; import { join } from "node:path"; import { @@ -8,6 +9,7 @@ import { spawnWithForwardedSignals, withRuntimePortEnv, } from "./runtime-env.mjs"; +import { bootstrapEnv } from "./bootstrap-env.mjs"; const mode = process.argv[2] === "start" ? "start" : "dev"; const cwd = process.cwd(); @@ -15,9 +17,16 @@ const appDir = join(cwd, "app"); const srcAppDir = join(cwd, "src", "app"); const appPage = join(appDir, "page.tsx"); const backupDir = join(cwd, "app.__qa_backup"); +const buildScript = join(cwd, "scripts", "build-next-isolated.mjs"); +const standaloneServer = join(cwd, testDistDir(), "standalone", "server.js"); +const buildIdFile = join(cwd, testDistDir(), "BUILD_ID"); let appDirMoved = false; +function testDistDir() { + return process.env.NEXT_DIST_DIR || ".next"; +} + function shouldMoveAppDir() { return existsSync(appDir) && !existsSync(appPage) && existsSync(srcAppDir); } @@ -54,23 +63,93 @@ process.on("uncaughtException", (error) => { prepareAppDir(); -const runtimePorts = resolveRuntimePorts(); +const bootstrapEnvVars = bootstrapEnv({ quiet: true }); +const runtimePorts = resolveRuntimePorts(bootstrapEnvVars); const testServerEnv = { + ...sanitizeColorEnv(bootstrapEnvVars), ...sanitizeColorEnv(process.env), + NEXT_PUBLIC_OMNIROUTE_E2E_MODE: process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE || "1", + OMNIROUTE_DISABLE_BACKGROUND_SERVICES: process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES || "1", OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK || "1", + OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK || "1", OMNIROUTE_HIDE_HEALTHCHECK_LOGS: process.env.OMNIROUTE_HIDE_HEALTHCHECK_LOGS || "1", }; -const args = [ - "./node_modules/next/dist/bin/next", - mode, - "--port", - String(runtimePorts.dashboardPort), -]; -if (mode === "dev") { - args.splice(2, 0, "--webpack"); + +function runChild(command, args, env) { + return new Promise((resolve) => { + const child = spawn(command, args, { + stdio: "inherit", + env, + }); + + const forward = (signal) => { + if (!child.killed) child.kill(signal); + }; + + process.on("SIGINT", forward); + process.on("SIGTERM", forward); + + child.on("exit", (code, signal) => { + process.off("SIGINT", forward); + process.off("SIGTERM", forward); + resolve({ code: code ?? 1, signal: signal ?? null }); + }); + }); } -spawnWithForwardedSignals(process.execPath, args, { - stdio: "inherit", - env: withRuntimePortEnv(testServerEnv, runtimePorts), -}); +async function runBuildForStart() { + if (mode !== "start") return; + if (process.env.OMNIROUTE_PLAYWRIGHT_SKIP_BUILD === "1") return; + if (existsSync(buildIdFile)) return; + + const buildEnv = withRuntimePortEnv(testServerEnv, runtimePorts); + const result = await runChild(process.execPath, [buildScript], buildEnv); + + if (result.signal) { + process.kill(process.pid, result.signal); + return; + } + + if (result.code !== 0) { + process.exit(result.code); + } +} + +await runBuildForStart(); +if (mode === "start") { + if (existsSync(standaloneServer)) { + spawnWithForwardedSignals(process.execPath, [standaloneServer], { + stdio: "inherit", + env: { + ...withRuntimePortEnv(testServerEnv, runtimePorts), + PORT: String(runtimePorts.dashboardPort), + HOSTNAME: process.env.HOSTNAME || "127.0.0.1", + }, + }); + } else { + const args = [ + "./node_modules/next/dist/bin/next", + "start", + "--port", + String(runtimePorts.dashboardPort), + ]; + + spawnWithForwardedSignals(process.execPath, args, { + stdio: "inherit", + env: withRuntimePortEnv(testServerEnv, runtimePorts), + }); + } +} else { + const args = [ + "./node_modules/next/dist/bin/next", + mode, + "--webpack", + "--port", + String(runtimePorts.dashboardPort), + ]; + + spawnWithForwardedSignals(process.execPath, args, { + stdio: "inherit", + env: withRuntimePortEnv(testServerEnv, runtimePorts), + }); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx index 6dbb78ca56..3e151821e5 100644 --- a/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx @@ -102,7 +102,7 @@ export default function MemorySkillsTab() { if (loading) { return ( - +
{STRATEGIES.map((s) => (