From cca615eaffa84b6c16e9c98ab3dd014b07b577e7 Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 24 Apr 2026 16:14:18 +0700 Subject: [PATCH] - Cap maximum cooldown for rate limit handling in account unavailability and single-model chat flows - Dynamic custom model fetching for model selection --- CHANGELOG.md | 6 +++ cloud/src/handlers/chat.js | 3 +- open-sse/config/errorConfig.js | 3 ++ open-sse/config/providerModels.js | 2 +- open-sse/handlers/chatCore.js | 4 +- open-sse/rtk/flag.js | 11 ------ open-sse/rtk/index.js | 33 ++++++++++++---- open-sse/services/combo.js | 9 +++++ open-sse/translator/index.js | 4 +- package.json | 2 +- .../cli-tools/components/CodexToolCard.js | 1 + src/app/api/cli-tools/codex-settings/route.js | 5 ++- src/app/api/combos/[id]/route.js | 10 +++++ src/app/api/settings/route.js | 12 ++++-- src/app/layout.js | 1 - src/lib/rtk/initRtk.js | 20 ---------- src/shared/components/ModelSelectModal.js | 38 +++++++++++++++---- src/sse/handlers/chat.js | 1 + src/sse/services/auth.js | 3 +- 19 files changed, 108 insertions(+), 60 deletions(-) delete mode 100644 open-sse/rtk/flag.js delete mode 100644 src/lib/rtk/initRtk.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a9c623d5..f0909a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# v0.4.5 (2026-04-24) + +## Improvements +- Cap maximum cooldown for rate limit handling in account unavailability and single-model chat flows +- Dynamic custom model fetching for model selection + # v0.4.3 (2026-04-24) ## Improvements diff --git a/cloud/src/handlers/chat.js b/cloud/src/handlers/chat.js index 0bcb9c7e..4ed8e410 100644 --- a/cloud/src/handlers/chat.js +++ b/cloud/src/handlers/chat.js @@ -2,6 +2,7 @@ import { getModelInfoCore } from "open-sse/services/model.js"; import { handleChatCore } from "open-sse/handlers/chatCore.js"; import { errorResponse } from "open-sse/utils/error.js"; import { checkFallbackError, isAccountUnavailable, getUnavailableUntil, getEarliestRateLimitedUntil, formatRetryAfter } from "open-sse/services/accountFallback.js"; +import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js"; import { getComboModelsFromData, handleComboChat } from "open-sse/services/combo.js"; import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; import * as log from "../utils/logger.js"; @@ -253,7 +254,7 @@ async function markAccountUnavailable(machineId, connectionId, status, errorText // Provider-specific precise cooldown (e.g. codex usage_limit_reached) overrides backoff let cooldownMs, newBackoffLevel; if (resetsAtMs && resetsAtMs > Date.now()) { - cooldownMs = resetsAtMs - Date.now(); + cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS); newBackoffLevel = 0; } else { ({ cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel)); diff --git a/open-sse/config/errorConfig.js b/open-sse/config/errorConfig.js index 48771c30..71491a4d 100644 --- a/open-sse/config/errorConfig.js +++ b/open-sse/config/errorConfig.js @@ -38,6 +38,9 @@ export const BACKOFF_CONFIG = { // Default cooldown for transient/unknown errors export const TRANSIENT_COOLDOWN_MS = 30 * 1000; +// Hard cap for provider-reported rate limit cooldown (e.g. codex resets_at can be 5-6h) +export const MAX_RATE_LIMIT_COOLDOWN_MS = 30 * 60 * 1000; + // Cooldown durations (ms) const COOLDOWN = { long: 2 * 60 * 1000, diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 014def7d..409eabf8 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -219,7 +219,6 @@ export const PROVIDER_MODELS = { // Gemini 3.1 series { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, - { id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image Preview" }, // Gemini 3 series { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, // Gemini 2.5 series @@ -342,6 +341,7 @@ export const PROVIDER_MODELS = { { id: "DeepSeek-V3.2", name: "DeepSeek-V3.2" }, ], deepseek: [ + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, { id: "deepseek-chat", name: "DeepSeek V3.2 Chat" }, { id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" }, ], diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 63f43d12..79ec2f41 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -24,7 +24,7 @@ import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.j * @param {object} options.credentials - Provider credentials * @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses") */ -export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, sourceFormatOverride, providerThinking }) { +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, sourceFormatOverride, providerThinking }) { const { provider, model } = modelInfo; const requestStartTime = Date.now(); @@ -82,7 +82,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`); translatedBody = { ...body, model }; } else { - translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider, reqLogger, stripList, connectionId); + translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider, reqLogger, stripList, connectionId, rtkEnabled); if (!translatedBody) { trackPendingRequest(model, provider, connectionId, false, true); return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Failed to translate request for ${sourceFormat} → ${targetFormat}`); diff --git a/open-sse/rtk/flag.js b/open-sse/rtk/flag.js deleted file mode 100644 index 5edc5385..00000000 --- a/open-sse/rtk/flag.js +++ /dev/null @@ -1,11 +0,0 @@ -// Synchronous RTK toggle cache. Updated by /api/settings PATCH handler -// and initialized from DB on server boot. -let enabled = false; - -export function setRtkEnabled(value) { - enabled = Boolean(value); -} - -export function isRtkEnabled() { - return enabled; -} diff --git a/open-sse/rtk/index.js b/open-sse/rtk/index.js index 20d21df0..2d57b196 100644 --- a/open-sse/rtk/index.js +++ b/open-sse/rtk/index.js @@ -3,21 +3,38 @@ import { RAW_CAP, MIN_COMPRESS_SIZE } from "./constants.js"; import { autoDetectFilter } from "./autodetect.js"; import { safeApply } from "./applyFilter.js"; -import { isRtkEnabled } from "./flag.js"; - -export { isRtkEnabled, setRtkEnabled } from "./flag.js"; // Compress tool_result content in-place. Returns stats or null if disabled/failed. -export function compressMessages(body) { - if (!isRtkEnabled()) return null; - if (!body || !Array.isArray(body.messages)) return null; +export function compressMessages(body, enabled) { + if (!enabled) return null; + if (!body) return null; + // Support both OpenAI/Claude "messages" and OpenAI Responses "input" + const items = Array.isArray(body.messages) ? body.messages + : Array.isArray(body.input) ? body.input + : null; + if (!items) return null; const stats = { bytesBefore: 0, bytesAfter: 0, hits: [] }; try { - for (let i = 0; i < body.messages.length; i++) { - const msg = body.messages[i]; + for (let i = 0; i < items.length; i++) { + const msg = items[i]; if (!msg) continue; + // Shape 4: OpenAI Responses — top-level { type:"function_call_output", output: string | [{type:"input_text", text}] } + if (msg.type === "function_call_output") { + if (typeof msg.output === "string") { + msg.output = compressText(msg.output, stats, "openai-responses-string"); + } else if (Array.isArray(msg.output)) { + for (let k = 0; k < msg.output.length; k++) { + const part = msg.output[k]; + if (part && part.type === "input_text" && typeof part.text === "string") { + part.text = compressText(part.text, stats, "openai-responses-array"); + } + } + } + continue; + } + // Shape 1: OpenAI tool message — { role:"tool", content: "string" } if (msg.role === "tool" && typeof msg.content === "string") { msg.content = compressText(msg.content, stats, "openai-tool"); diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index cefbac38..34598b64 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -39,6 +39,15 @@ export function getRotatedModels(models, comboName, strategy) { return rotatedModels; } +/** + * Reset in-memory rotation state when combo/settings change + * @param {string} [comboName] - Combo name to reset; omit to clear all + */ +export function resetComboRotation(comboName) { + if (comboName) comboRotationState.delete(comboName); + else comboRotationState.clear(); +} + /** * Get combo models from combos data * @param {string} modelStr - Model string to check diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 89443086..6b20862b 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -71,12 +71,12 @@ function stripContentTypes(body, stripList = []) { } // Translate request: source -> openai -> target -export function translateRequest(sourceFormat, targetFormat, model, body, stream = true, credentials = null, provider = null, reqLogger = null, stripList = [], connectionId = null) { +export function translateRequest(sourceFormat, targetFormat, model, body, stream = true, credentials = null, provider = null, reqLogger = null, stripList = [], connectionId = null, rtkEnabled = false) { ensureInitialized(); let result = body; // RTK: compress tool_result content before any translation (shape-agnostic) - const rtkStats = compressMessages(result); + const rtkStats = compressMessages(result, rtkEnabled); if (rtkStats) { const line = formatRtkLog(rtkStats); if (line) console.log(line); diff --git a/package.json b/package.json index caa9a568..709ea5b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.3", + "version": "0.4.5", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index 8a710c94..f46976f8 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -174,6 +174,7 @@ model = "${effectiveSubagentModel}" `; const authContent = JSON.stringify({ + auth_mode: "apikey", OPENAI_API_KEY: keyToUse }, null, 2); diff --git a/src/app/api/cli-tools/codex-settings/route.js b/src/app/api/cli-tools/codex-settings/route.js index 425e53b3..ff20c575 100644 --- a/src/app/api/cli-tools/codex-settings/route.js +++ b/src/app/api/cli-tools/codex-settings/route.js @@ -159,7 +159,9 @@ export async function POST(request) { authData = JSON.parse(existingAuth); } catch { /* No existing auth */ } + // Force apikey mode (keep existing tokens untouched for ChatGPT login reuse) authData.OPENAI_API_KEY = apiKey; + authData.auth_mode = "apikey"; await fs.writeFile(authPath, JSON.stringify(authData, null, 2)); return NextResponse.json({ @@ -215,7 +217,8 @@ export async function DELETE() { const existingAuth = await fs.readFile(authPath, "utf-8"); const authData = JSON.parse(existingAuth); delete authData.OPENAI_API_KEY; - + delete authData.auth_mode; + // Write back or delete if empty if (Object.keys(authData).length === 0) { await fs.unlink(authPath); diff --git a/src/app/api/combos/[id]/route.js b/src/app/api/combos/[id]/route.js index 0eae2ce6..d641da7f 100644 --- a/src/app/api/combos/[id]/route.js +++ b/src/app/api/combos/[id]/route.js @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getComboById, updateCombo, deleteCombo, getComboByName } from "@/lib/localDb"; +import { resetComboRotation } from "open-sse/services/combo.js"; // Validate combo name: only a-z, A-Z, 0-9, -, _ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; @@ -40,12 +41,18 @@ export async function PUT(request, { params }) { } } + // Capture previous name to invalidate rotation state on rename + const prev = await getComboById(id); const combo = await updateCombo(id, body); if (!combo) { return NextResponse.json({ error: "Combo not found" }, { status: 404 }); } + // Invalidate rotation state (models/strategy/name may have changed) + if (prev?.name) resetComboRotation(prev.name); + if (combo.name && combo.name !== prev?.name) resetComboRotation(combo.name); + return NextResponse.json(combo); } catch (error) { console.log("Error updating combo:", error); @@ -57,11 +64,14 @@ export async function PUT(request, { params }) { export async function DELETE(request, { params }) { try { const { id } = await params; + const prev = await getComboById(id); const success = await deleteCombo(id); if (!success) { return NextResponse.json({ error: "Combo not found" }, { status: 404 }); } + + if (prev?.name) resetComboRotation(prev.name); return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index 1883a4ab..ddd91c19 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { getSettings, updateSettings } from "@/lib/localDb"; import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy"; -import { setRtkEnabled } from "open-sse/rtk/flag.js"; +import { resetComboRotation } from "open-sse/services/combo.js"; import bcrypt from "bcryptjs"; export async function GET() { @@ -67,10 +67,14 @@ export async function PATCH(request) { applyOutboundProxyEnv(settings); } - // Sync RTK toggle immediately (sync cache for request hot path) - if (Object.prototype.hasOwnProperty.call(body, "rtkEnabled")) { - setRtkEnabled(settings.rtkEnabled); + // Invalidate combo rotation state when strategy settings change + if ( + Object.prototype.hasOwnProperty.call(body, "comboStrategy") || + Object.prototype.hasOwnProperty.call(body, "comboStrategies") + ) { + resetComboRotation(); } + const { password, ...safeSettings } = settings; return NextResponse.json(safeSettings); } catch (error) { diff --git a/src/app/layout.js b/src/app/layout.js index aff9df05..9839a5da 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -3,7 +3,6 @@ import "./globals.css"; import { ThemeProvider } from "@/shared/components/ThemeProvider"; import "@/lib/initCloudSync"; // Auto-initialize cloud sync import "@/lib/network/initOutboundProxy"; // Auto-initialize outbound proxy env -import "@/lib/rtk/initRtk"; // Auto-initialize RTK toggle from DB import { initConsoleLogCapture } from "@/lib/consoleLogBuffer"; import { RuntimeI18nProvider } from "@/i18n/RuntimeI18nProvider"; diff --git a/src/lib/rtk/initRtk.js b/src/lib/rtk/initRtk.js deleted file mode 100644 index 95a3bee1..00000000 --- a/src/lib/rtk/initRtk.js +++ /dev/null @@ -1,20 +0,0 @@ -import { getSettings } from "@/lib/localDb"; -import { setRtkEnabled } from "open-sse/rtk/flag.js"; - -let initialized = false; - -export async function ensureRtkInitialized() { - if (initialized) return true; - try { - const settings = await getSettings(); - setRtkEnabled(settings.rtkEnabled === true); - initialized = true; - } catch (error) { - console.error("[ServerInit] Error initializing RTK flag:", error); - } - return initialized; -} - -ensureRtkInitialized().catch(console.log); - -export default ensureRtkInitialized; diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index e56ae09a..258f1214 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -3,8 +3,8 @@ import { useState, useMemo, useEffect } from "react"; import PropTypes from "prop-types"; import Modal from "./Modal"; -import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; +import { getModelsByProviderId } from "@/shared/constants/models"; +import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, getProviderAlias } from "@/shared/constants/providers"; // Provider order: OAuth first, then Free Tier, then API Key (matches dashboard/providers) const PROVIDER_ORDER = [ @@ -29,6 +29,7 @@ export default function ModelSelectModal({ const [searchQuery, setSearchQuery] = useState(""); const [combos, setCombos] = useState([]); const [providerNodes, setProviderNodes] = useState([]); + const [customModels, setCustomModels] = useState([]); const fetchCombos = async () => { try { @@ -62,6 +63,22 @@ export default function ModelSelectModal({ if (isOpen) fetchProviderNodes(); }, [isOpen]); + const fetchCustomModels = async () => { + try { + const res = await fetch("/api/models/custom"); + if (!res.ok) throw new Error(`Failed to fetch custom models: ${res.status}`); + const data = await res.json(); + setCustomModels(data.models || []); + } catch (error) { + console.error("Error fetching custom models:", error); + setCustomModels([]); + } + }; + + useEffect(() => { + if (isOpen) fetchCustomModels(); + }, [isOpen]); + const allProviders = useMemo(() => ({ ...OAUTH_PROVIDERS, ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...APIKEY_PROVIDERS }), []); // Group models by provider with priority order @@ -85,7 +102,7 @@ export default function ModelSelectModal({ }); sortedProviderIds.forEach((providerId) => { - const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; + const alias = getProviderAlias(providerId); const providerInfo = allProviders[providerId] || { name: providerId, color: "#666" }; const isCustomProvider = isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId); @@ -151,7 +168,7 @@ export default function ModelSelectModal({ // Custom models: if no hardcoded models (e.g. openrouter), show all aliases for this provider // Otherwise only show aliases where aliasName === modelId ("Add Model" button pattern) const hasHardcoded = hardcodedModels.length > 0; - const customModels = Object.entries(modelAliases) + const customAliasModels = Object.entries(modelAliases) .filter(([aliasName, fullModel]) => fullModel.startsWith(`${alias}/`) && (hasHardcoded ? aliasName === fullModel.replace(`${alias}/`, "") : true) && @@ -162,9 +179,16 @@ export default function ModelSelectModal({ return { id: modelId, name: aliasName, value: fullModel, isCustom: true }; }); + // Custom models registered via /api/models/custom (provider "Add Model" button) + const customAliasIds = new Set(customAliasModels.map((m) => m.id)); + const customRegisteredModels = customModels + .filter((m) => m.providerAlias === alias && !hardcodedIds.has(m.id) && !customAliasIds.has(m.id)) + .map((m) => ({ id: m.id, name: m.name || m.id, value: `${alias}/${m.id}`, isCustom: true })); + const allModels = [ ...hardcodedModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}` })), - ...customModels, + ...customAliasModels, + ...customRegisteredModels, ]; if (allModels.length > 0) { @@ -179,7 +203,7 @@ export default function ModelSelectModal({ }); return groups; - }, [activeProviders, modelAliases, allProviders, providerNodes]); + }, [activeProviders, modelAliases, allProviders, providerNodes, customModels]); // Filter combos by search query const filteredCombos = useMemo(() => { @@ -304,7 +328,7 @@ export default function ModelSelectModal({ const isPlaceholder = model.isPlaceholder; return (