From f0adfb205a0e1f61e167683d85e670baab721ed2 Mon Sep 17 00:00:00 2001 From: luulam Date: Thu, 27 Aug 2026 09:26:17 +0700 Subject: [PATCH] feat(dashboard): per-key model restrictions, pin header routing, combo side-panel picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Endpoint: per-API-key model allowlist (schema v3) enforced on chat (403) and /v1/models; Full-access toggle + multi-select picker in Keys UI. - Providers: honor x-connection-id in /v1/chat/completions — pinned requests no longer rotate to another account on failure. - Providers: strategy saves merge into stored enabled:false override; Test All groups match grid sections; 1-by-1 skips disabled connections. - Dashboard: provider-card toggle syncs from server on failure; grid toggles always visible; connection rows get clear-✕ for stale error banners. - Combo editor: on desktop (xl+) the Add-Model picker opens as a floating side panel beside the untouched combo popup instead of stacking on top; mobile keeps the full-screen overlay. - Long API-key overflow fixed in key rows + provider model sections. --- CHANGELOG.md | 53 ++++ CLAUDE.md | 10 + open-sse/AGENTS.md | 2 +- open-sse/config/errorConfig.js | 11 + open-sse/handlers/chatCore.js | 40 ++- .../handlers/chatCore/nonStreamingHandler.js | 20 +- open-sse/handlers/chatCore/requestDetail.js | 2 +- .../handlers/chatCore/sseToJsonHandler.js | 2 +- open-sse/services/accountFallback.js | 8 +- open-sse/services/compact.js | 71 ------ open-sse/utils/requestLogger.js | 31 +-- open-sse/utils/stream.js | 9 +- src/app/(dashboard)/dashboard/combos/page.js | 44 ++-- .../dashboard/endpoint/EndpointPageClient.js | 161 +++++++++++- .../media-providers/combo/[id]/page.js | 15 +- .../providers/[id]/CompatibleModelsSection.js | 2 +- .../dashboard/providers/[id]/ConnectionRow.js | 12 +- .../[id]/PassthroughModelsSection.js | 2 +- .../dashboard/providers/[id]/page.js | 241 ++++++++++++++---- .../providers/components/ConnectionsCard.js | 5 +- .../providers/components/ModelsCard.js | 6 +- .../(dashboard)/dashboard/providers/page.js | 42 ++- src/app/api/keys/[id]/route.js | 9 +- src/app/api/providers/test-batch/route.js | 31 ++- src/app/api/v1/models/route.js | 20 +- src/lib/db/index.js | 4 +- src/lib/db/repos/apiKeysRepo.js | 18 +- src/lib/db/repos/usageRepo.js | 33 +-- src/lib/db/schema.js | 4 +- src/lib/localDb.js | 2 +- src/lib/usageDb.js | 2 +- src/shared/components/ApiExplorerModal.js | 2 +- src/shared/components/ComboFormModal.js | 144 ++++++----- src/shared/components/Modal.js | 30 +++ src/shared/components/ModelSelectModal.js | 18 +- src/shared/components/RequestLogger.js | 28 +- src/shared/components/index.js | 2 +- src/sse/handlers/chat.js | 22 +- src/sse/services/auth.js | 51 +++- tests/unit/chat-connection-pin.test.js | 115 +++++++++ tests/unit/github-monthly-usage-lock.test.js | 2 +- tests/unit/request-scoped-fallback.test.js | 41 +++ 42 files changed, 1028 insertions(+), 339 deletions(-) delete mode 100644 open-sse/services/compact.js create mode 100644 tests/unit/chat-connection-pin.test.js create mode 100644 tests/unit/request-scoped-fallback.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb1a186..8f9c7637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,56 @@ +# Unreleased +## Features +- **Endpoint**: per-API-key model restrictions. Each key in Dashboard → API + Keys gets a "Restrict models" action (tune icon) opening a modal with a + Full-access toggle (default) and a multi-select model picker (reuses + ModelSelectModal — provider-grouped chips, search, combos). Restricted keys + get HTTP 403 on chat calls outside the list (checked before combo expansion, + so a combo routing into a disallowed model is also blocked) and /v1/models + returns only the allowed models for that key; keys without a restriction + keep full access and see everything. + +## Fixes +- **Providers**: honor `x-connection-id` in `/v1/chat/completions` — the + dashboard's per-key Test (and any client pinning) previously routed through + whichever account the strategy picked, so "test key X" could pass or fail on + account Y. Pinned requests now select exactly that account and do not rotate + on failure, matching the embeddings/images/video handlers. +- **Providers**: saving a per-provider fallback strategy no longer wipes a + stored `enabled: false` override — toggling a free provider off and then + changing Round-Robin silently re-enabled it. Strategy saves now merge into + the existing override (detail page and media-provider ConnectionsCard). +- **Providers**: Test All group filters match the grid sections — freeTier + providers' API-key connections were batch-tested under "apikey" while their + cards render in Free Tier; Custom Providers had no Test All button. +- **Providers**: Test Connection One-by-One skips disabled connections instead + of testing-and-failing them (same population as routing and bulk test). +- **Dashboard**: provider-card enable/disable failures surface a notification + and re-sync from the server instead of silently diverging from the DB. + +## UX +- **Providers**: Test Selected Keys modal lists the selected accounts (with a + count) before the model picker; each result row fills in live as its test + settles, and after a run two actions appear — Disable Failed (kills the + failing accounts, marked "disabled" in the list) and Retry Failed (re-runs + only the accounts that failed). +- **Providers**: connections toolbar groups bulk actions (proxy / delete / + disable / test) apart from provider-wide settings (Connect Timeout, + Round Robin) with a divider; "Test Connection One-by-One" renamed to + "Test All Connections (1-by-1)" so it reads as the complement of + "Test Selected (N)". +- **Dashboard**: grid-card toggles are always visible instead of + hover-revealed — hover-only affordances are unusable on touch devices. + Connectable free providers (Kiro, Gemini CLI) with zero connections keep an + enable/disable toggle (free bandwidth tiers have no per-connection switch), + with a "Free · Connect" badge alongside; noAuth providers show "Ready". +- **Providers**: connection rows get a ✕ next to stale error banners; clearing + persists `lastError`/`lastErrorAt` to the server so the banner doesn't + re-appear on reload. + +## Tests +- `tests/unit/chat-connection-pin.test.js`: pin header forwarded as + `preferredConnectionId`; unpinned requests stay unpinned. + # v0.5.55 (2026-08-14) ## Features diff --git a/CLAUDE.md b/CLAUDE.md index d7c21345..a8d933cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,3 +89,13 @@ Pre-translate hooks that compress `tool_result` content in-place to cut tokens. - Security-sensitive env: `JWT_SECRET` (session cookie), `INITIAL_PASSWORD` (default `123456` — must override), `API_KEY_SECRET`, `MACHINE_ID_SALT`. Full env contract in `.env.example` and ARCHITECTURE.md's env matrix. - Binary/protobuf upstreams (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — they're handled inside their own executor, not the translator. - Versioning: root and `cli/` are versioned independently; changes are logged in `CHANGELOG.md`. Commit style is Conventional Commits (`fix(translator): …`, `feat(...)`). + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/open-sse/AGENTS.md b/open-sse/AGENTS.md index 0482fc32..068855e5 100644 --- a/open-sse/AGENTS.md +++ b/open-sse/AGENTS.md @@ -16,7 +16,7 @@ Provider-agnostic SSE engine: one OpenAI-style request → any provider (LLM cha - `rtk/` — request token-killer. `index.js` compresses `tool_result` content in-place (OpenAI/Claude/Kiro shapes); `filters/` per-tool compressors + `autodetect.js`; `headroom.js` external compress proxy; `caveman.js` system-prompt injector. - `transformer/` — `responsesTransformer.js` (Chat Completions SSE → Codex Responses API SSE), `streamToJsonConverter.js`. - `shared/` — cross-provider auth/identity: `clineAuth.js`, `machineId.js`, `qoder/`. -- `services/` — `model.js`, `provider.js`, `accountFallback.js`, `combo.js`, `compact.js`, `tokenRefresh/`+`tokenRefresh.js`, `oauthCredentialManager.js`, `usage/`, `projectId.js`, `kiroModels.js`/`qoderModels.js`. +- `services/` — `model.js`, `provider.js`, `accountFallback.js`, `combo.js`, `tokenRefresh/`+`tokenRefresh.js`, `oauthCredentialManager.js`, `usage/`, `projectId.js`, `kiroModels.js`/`qoderModels.js`. - `utils/` — streamHandler, stream, sse, error, sessionManager, claudeCloaking, clientDetector, proxyFetch (patches global fetch), cursorProtobuf/cursorChecksum, ollamaTransform. ## Conventions diff --git a/open-sse/config/errorConfig.js b/open-sse/config/errorConfig.js index 71491a4d..9b742b24 100644 --- a/open-sse/config/errorConfig.js +++ b/open-sse/config/errorConfig.js @@ -73,6 +73,17 @@ export const ERROR_RULES = [ { status: 403, cooldownMs: COOLDOWN.long }, { status: 404, cooldownMs: COOLDOWN.long }, { status: 429, backoff: true }, + // --- Request-scoped errors: the request itself is broken — retrying the same + // body on another account/model can never succeed, and locking the account + // would punish a healthy credential for our own bad request. Callers use this + // to fail fast (no account rotation, no model lock). + { text: "context_length_exceeded", requestScoped: true }, + { text: "context window", requestScoped: true }, + { text: "maximum context length", requestScoped: true }, + { text: "prompt is too long", requestScoped: true }, + { text: "input is too long", requestScoped: true }, + { text: "max_tokens exceed", requestScoped: true }, + { text: "reduce the length", requestScoped: true }, ]; // Backward compat: COOLDOWN_MS object (used by index.js re-export) diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 88356471..7ef1a2f1 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -11,7 +11,7 @@ import { PROVIDERS } from "../config/providers.js"; import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js"; import { handleBypassRequest } from "../utils/bypassHandler.js"; -import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; +import { trackPendingRequest, saveRequestDetail } from "@/lib/usageDb.js"; import { getExecutor } from "../executors/index.js"; import { supportsGrokCliReasoningEffort } from "../config/grokCli.js"; import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js"; @@ -29,6 +29,7 @@ import { getCapabilitiesForModel } from "../providers/capabilities.js"; import { stripUnsupportedModalities } from "../translator/concerns/modality.js"; import { prefetchRemoteImages } from "../translator/concerns/prefetch.js"; import { resolveSessionId } from "../utils/sessionManager.js"; +import { maybeRejectEarlyStreamError } from "../utils/streamErrorPeek.js"; /** * Core chat handler - shared between SSE and Worker @@ -57,7 +58,7 @@ export function stripContinuityFields(body) { return body; } -export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, capsOverride }) { +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, capsOverride, streamErrorPatterns }) { const { provider, model } = modelInfo; const requestStartTime = Date.now(); // Stable per-session color so all lines of one CLI conversation share a tag @@ -100,7 +101,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred if (providerThinking?.mode && providerThinking.mode !== "auto") { const mode = providerThinking.mode; if (mode === "on" && !body.thinking) { - console.log("Injecting provider-level thinking config override: on"); + log?.debug?.("THINKING", `provider-level override: on`); body = { ...body, thinking: { type: "enabled", budget_tokens: 10000 } }; } else if (mode === "off" && !body.thinking) { body = { ...body, thinking: { type: "disabled" } }; @@ -243,7 +244,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // RTK: compress tool_result content const rtkStats = compressMessages(translatedBody, tokenSaverEnabled && rtkEnabled); const rtkLine = formatRtkLog(rtkStats); - if (rtkLine) console.log(rtkLine); + if (rtkLine) log?.info?.("RTK", rtkLine.replace(/^\[RTK\] /, "")); // Headroom: optional external proxy compression; fail open if proxy is absent. const headroomDiagnostics = {}; @@ -293,7 +294,6 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const executor = getExecutor(provider); trackPendingRequest(model, provider, connectionId, true); - appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { }); const msgCount = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || translatedBody.request?.contents?.length || 0; log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`); @@ -355,7 +355,6 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); } catch (error) { trackPendingRequest(model, provider, connectionId, false, true); - appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { }); saveRequestDetail(buildRequestDetail({ provider, model, connectionId, latency: { ttft: 0, total: Date.now() - requestStartTime }, @@ -415,11 +414,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } } + // Provider returned error if (!providerResponse.ok) { trackPendingRequest(model, provider, connectionId, false, true); const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor); - appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { }); saveRequestDetail(buildRequestDetail({ provider, model, connectionId, latency: { ttft: 0, total: Date.now() - requestStartTime }, @@ -440,8 +439,31 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred return createErrorResult(statusCode, errMsg, resetsAtMs); } - const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary, reqTag, log }; - const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { }); + const appendLog = () => {}; // request log derived from usageHistory; kept as no-op seam for handlers + const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary, reqTag, log, streamErrorPatterns }; + + // Early-peek streaming responses for configured in-stream error patterns. + // Some upstreams fail INSIDE a 200 SSE stream; without this the failure is + // piped to the client verbatim and account/combo fallback never triggers + // (see AGENTS.md "HTTP 200 in-stream errors"). Fail-open: no patterns → pass-through. + if (providerResponse.ok && stream) { + const peeked = await maybeRejectEarlyStreamError( + providerResponse, + streamErrorPatterns?.[provider], + { signal: streamController.signal }, + ); + if (!peeked.ok) { + const { message } = await parseUpstreamError(peeked).catch(() => ({ message: "Stream error pattern matched" })); + trackPendingRequest(model, provider, connectionId, false, true); + appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` }); + if (log?.errorLine) { + log.errorLine(reqTag, "✗", `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms (in-stream)\n ${message}`); + } + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, message); + } + providerResponse = peeked; + } + const trackDone = () => trackPendingRequest(model, provider, connectionId, false); // Provider forced streaming but client wants JSON diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 5489b344..ce713f6a 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -7,7 +7,8 @@ import { createErrorResult } from "../../utils/error.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js"; import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js"; -import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; +import { saveRequestDetail } from "@/lib/usageDb.js"; +import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.js"; import { decloakToolNames } from "../../utils/claudeCloaking.js"; import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js"; @@ -281,7 +282,7 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source /** * Handle non-streaming response from provider. */ -export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, trackDone, appendLog, pxpipe, reqTag, log }) { +export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, trackDone, appendLog, pxpipe, reqTag, log, streamErrorPatterns }) { trackDone(); const contentType = providerResponse.headers.get("content-type") || ""; let responseBody; @@ -316,6 +317,21 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m // Decloak tool_use names once on raw Claude body, before any translation (INPUT side) responseBody = decloakToolNames(responseBody, toolNameMap); + // Config-driven in-stream error detection: the HTTP call succeeded but the + // assembled content signals an upstream failure — treat it as an error so + // account/combo fallback and FAILED logging kick in (AGENTS.md hook #3). + const matchedPattern = matchStreamErrorPatterns( + streamErrorPatterns?.[provider], + responseBody?.choices?.[0]?.message?.content || responseBody?.content || "", + ); + if (matchedPattern) { + appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` }); + if (log?.errorLine) { + log.errorLine(reqTag, "✗", `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms (in-stream)\n Stream error pattern matched: ${matchedPattern}`); + } + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Stream error pattern matched: ${matchedPattern}`); + } + const usage = extractUsageFromResponse(responseBody); appendLog({ tokens: usage, status: "200 OK" }); saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true }); diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js index ebd13df3..064733d9 100644 --- a/open-sse/handlers/chatCore/requestDetail.js +++ b/open-sse/handlers/chatCore/requestDetail.js @@ -1,4 +1,4 @@ -import { saveRequestUsage, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; +import { saveRequestUsage, saveRequestDetail } from "@/lib/usageDb.js"; import { COLORS } from "../../utils/stream.js"; import { canonicalizeUsage } from "../../utils/usageTracking.js"; diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index b169d18d..ccb782fa 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -5,12 +5,12 @@ import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { FORMATS } from "../../translator/formats.js"; import { PROVIDERS } from "../../config/providers.js"; import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js"; +import { saveRequestDetail } from "@/lib/usageDb.js"; import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js"; // Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES; -import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.js"; function textFromResponsesMessageItem(item) { if (!item?.content || !Array.isArray(item.content)) return ""; diff --git a/open-sse/services/accountFallback.js b/open-sse/services/accountFallback.js index 8d280da4..7a3195e5 100644 --- a/open-sse/services/accountFallback.js +++ b/open-sse/services/accountFallback.js @@ -26,8 +26,14 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) { : ""; for (const rule of ERROR_RULES) { + // Request-scoped rule: the request body itself is at fault — no cooldown, + // no account lock. Caller must stop rotating and surface the error. + if (rule.requestScoped && lowerError && lowerError.includes(rule.text)) { + return { shouldFallback: false, requestScoped: true, cooldownMs: 0 }; + } + // Text-based rule: match substring in error message - if (rule.text && lowerError && lowerError.includes(rule.text)) { + if (rule.text && !rule.requestScoped && lowerError && lowerError.includes(rule.text)) { if (rule.backoff) { const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel); return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel }; diff --git a/open-sse/services/compact.js b/open-sse/services/compact.js deleted file mode 100644 index 812cd27b..00000000 --- a/open-sse/services/compact.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Shared combo (model combo) handling with fallback support - */ - -/** - * Get combo models from combos data - * @param {string} modelStr - Model string to check - * @param {Array|Object} combosData - Array of combos or object with combos - * @returns {string[]|null} Array of models or null if not a combo - */ -export function getComboModelsFromData(modelStr, combosData) { - // Don't check if it's in provider/model format - if (modelStr.includes("/")) return null; - - // Handle both array and object formats - const combos = Array.isArray(combosData) ? combosData : (combosData?.combos || []); - - const combo = combos.find(c => c.name === modelStr); - if (combo && combo.models && combo.models.length > 0) { - return combo.models; - } - return null; -} - -/** - * Handle combo chat with fallback - * @param {Object} options - * @param {Object} options.body - Request body - * @param {string[]} options.models - Array of model strings to try - * @param {Function} options.handleSingleModel - Function to handle single model: (body, modelStr) => Promise - * @param {Object} options.log - Logger object - * @returns {Promise} - */ -export async function handleComboChat({ body, models, handleSingleModel, log }) { - let lastError = null; - - for (let i = 0; i < models.length; i++) { - const modelStr = models[i]; - log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`); - - let result; - try { - result = await handleSingleModel(body, modelStr); - } catch (e) { - lastError = `${modelStr}: ${e.message}`; - log.warn("COMBO", `Model threw exception, trying next`, { model: modelStr, error: e.message }); - continue; - } - - // Success or client error - return response - if (result.ok || result.status < 500) { - return result; - } - - // 5xx error - try next model - lastError = `${modelStr}: ${result.statusText || result.status}`; - log.warn("COMBO", `Model failed, trying next`, { model: modelStr, status: result.status }); - } - - log.warn("COMBO", "All models failed"); - - // Return 503 with last error - return new Response( - JSON.stringify({ error: lastError || "All combo models unavailable" }), - { - status: 503, - headers: { "Content-Type": "application/json" } - } - ); -} - diff --git a/open-sse/utils/requestLogger.js b/open-sse/utils/requestLogger.js index 010153d3..fae160f2 100644 --- a/open-sse/utils/requestLogger.js +++ b/open-sse/utils/requestLogger.js @@ -69,25 +69,22 @@ function writeJsonFile(sessionPath, filename, data) { } } -// Mask sensitive data in headers (DISABLED - keep full token for testing) +// Mask sensitive headers before writing to disk. ENABLE_REQUEST_LOGS dumps full +// request/response bodies; credentials inside must never land in plaintext. function maskSensitiveHeaders(headers) { if (!headers) return {}; - return { ...headers }; - - // Old masking code (disabled): - // const masked = { ...headers }; - // const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token"]; - // - // for (const key of Object.keys(masked)) { - // const lowerKey = key.toLowerCase(); - // if (sensitiveKeys.some(sk => lowerKey.includes(sk))) { - // const value = masked[key]; - // if (value && value.length > 20) { - // masked[key] = value.slice(0, 10) + "..." + value.slice(-5); - // } - // } - // } - // return masked; + const masked = { ...headers }; + const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token", "api-key"]; + for (const key of Object.keys(masked)) { + const lowerKey = key.toLowerCase(); + if (sensitiveKeys.some(sk => lowerKey.includes(sk))) { + const value = masked[key]; + if (typeof value === "string" && value.length > 20) { + masked[key] = value.slice(0, 10) + "..." + value.slice(-5); + } + } + } + return masked; } // No-op logger when logging is disabled diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 33e7fb04..58037339 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -1,6 +1,6 @@ import { translateResponse, initState } from "../translator/index.js"; import { FORMATS } from "../translator/formats.js"; -import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js"; +import { trackPendingRequest } from "@/lib/usageDb.js"; import { extractUsage, mergeUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js"; import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js"; import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js"; @@ -361,10 +361,7 @@ export function createSSEStream(options = {}) { if (hasValidUsage(usage)) { logUsage(provider, usage, model, connectionId, apiKey); - } else { - appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { }); } - // IMPORTANT: In passthrough mode we still must terminate the SSE stream. // Some clients (e.g. OpenClaw) expect the OpenAI-style sentinel: // data: [DONE]\n\n @@ -447,13 +444,9 @@ export function createSSEStream(options = {}) { if (!hasValidUsage(state?.usage) && totalContentLength > 0) { state.usage = estimateUsage(body, totalContentLength, sourceFormat); } - if (hasValidUsage(state?.usage)) { logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKey); - } else { - appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { }); } - if (onStreamComplete) { onStreamComplete({ content: accumulatedContent, diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index 5fa02323..16d16ba5 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -5,7 +5,7 @@ import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, us import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; -import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select, Toggle } from "@/shared/components"; +import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ModelSelectSidePanel, ConfirmModal, CapacityBadges, Select, Toggle } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useModelCaps } from "@/shared/hooks/useModelCaps"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; @@ -760,13 +760,23 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF const isEdit = !!combo; + const pickerCommon = { + onClose: () => setShowModelSelect(false), + onSelect: handleAddModel, + onDeselect: handleDeselectModel, + activeProviders, + modelAliases, + title: "Add Model to Combo", + kindFilter, + addedModelValues: models, + closeOnSelect: false, + }; + + return ( <> - + {/* Combo popup stays exactly as before on all breakpoints. */} +
{/* Name */}
@@ -845,20 +855,16 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
- {/* Model Select Modal */} + {/* Mobile / tablet: original stacked overlay */} {showModelSelect && ( - setShowModelSelect(false)} - onSelect={handleAddModel} - onDeselect={handleDeselectModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Add Model to Combo" - kindFilter={kindFilter} - addedModelValues={models} - closeOnSelect={false} - /> +
+ +
+ )} + + {/* Desktop (xl+): floating panel beside the popup, popup untouched */} + {showModelSelect && ( + )} ); diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 39f715e5..21017449 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import PropTypes from "prop-types"; -import { Card, Button, Input, Modal, CardSkeleton, Toggle, ConfirmModal, ApiExplorerModal } from "@/shared/components"; +import { Card, Button, Input, Modal, CardSkeleton, Toggle, ConfirmModal, ApiExplorerModal, ModelSelectModal } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { TUNNEL_BENEFITS, @@ -83,6 +83,13 @@ export default function APIPageClient({ machineId }) { // API key visibility toggle state const [visibleKeys, setVisibleKeys] = useState(new Set()); + // Per-key model restriction editor + const [restrictKey, setRestrictKey] = useState(null); // key row being edited + const [restrictSet, setRestrictSet] = useState(new Set()); // currently allowed models + const [restrictSaving, setRestrictSaving] = useState(false); + const [showModelPicker, setShowModelPicker] = useState(false); + const [providers, setProviders] = useState([]); + // Client-side local/remote detection (UI hint only, not a security gate) const [isRemoteHost, setIsRemoteHost] = useState(false); useEffect(() => { @@ -106,6 +113,10 @@ export default function APIPageClient({ machineId }) { useEffect(() => { fetchData(); loadSettings(); + fetch("/api/providers") + .then((r) => (r.ok ? r.json() : { connections: [] })) + .then((d) => setProviders(d.connections || [])) + .catch(() => setProviders([])); }, []); // Status poll: only while degraded (not yet reachable). Stop once healthy to avoid spam. @@ -702,6 +713,31 @@ export default function APIPageClient({ machineId }) { }); }; + const openRestrictModal = (key) => { + setRestrictKey(key); + setRestrictSet(new Set(key.allowedModels || [])); + }; + + const persistRestriction = async (keyId, list) => { + setRestrictSaving(true); + try { + const res = await fetch(`/api/keys/${keyId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ allowedModels: list }), + }); + if (res.ok) { + const data = await res.json(); + setKeys((prev) => prev.map((k) => (k.id === keyId ? { ...k, allowedModels: data.key?.allowedModels ?? (list.length ? list : null) } : k))); + setRestrictKey(null); + } + } catch (error) { + console.log("Error saving model restriction:", error); + } finally { + setRestrictSaving(false); + } + }; + const [baseUrl, setBaseUrl] = useState("/v1"); // Hydration fix: Only access window on client side @@ -1031,17 +1067,17 @@ export default function APIPageClient({ machineId }) { {keys.map((key) => (
-

{key.name}

+

{key.name}

- + {visibleKeys.has(key.id) ? key.key : maskKey(key.key)}

Created {new Date(key.createdAt).toLocaleDateString()} + {Array.isArray(key.allowedModels) && key.allowedModels.length > 0 && ( + + · {key.allowedModels.length} model{key.allowedModels.length > 1 ? "s" : ""} allowed + + )}

{key.isActive === false && (

Paused

)}
-
+
+
+ {/* Restrict Models Modal */} + setRestrictKey(null)} + > +
+
+
+

Full access

+

+ {restrictSet.size > 0 + ? `Limited to ${restrictSet.size} model${restrictSet.size > 1 ? "s" : ""}` + : "This key can use every model and combo"} +

+
+ { + if (checked) setRestrictSet(new Set()); + }} + title={restrictSet.size === 0 ? "Full access on" : "Restricted"} + /> +
+ + {restrictSet.size > 0 && ( +
+
+ {[...restrictSet].map((m) => ( + + {m} + + + ))} +
+ +
+ )} + {restrictSet.size === 0 && ( + + )} + +

+ Restricted keys get an error when calling other models, and /v1/models only lists the models above. +

+ +
+ + +
+
+
+ + {/* Per-key model picker (multi-select) */} + setShowModelPicker(false)} + onSelect={(model) => { + const value = model?.value || model?.name; + if (!value) return; + setRestrictSet((prev) => { + const next = new Set(prev); + next.add(value); + return next; + }); + }} + onDeselect={(model) => { + const value = model?.value || model?.name; + setRestrictSet((prev) => { + const next = new Set(prev); + next.delete(value); + return next; + }); + }} + addedModelValues={[...restrictSet]} + selectedModel={null} + activeProviders={(providers || []).map((p) => ({ provider: p.provider, id: p.id }))} + title={`Allowed models for "${restrictKey?.name || ""}"`} + closeOnSelect={false} + /> + {/* Created Key Modal */} String(n).padStart(2, "0"); + return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + export default function ComboDetailPage() { const { id } = useParams(); const router = useRouter(); @@ -88,7 +97,7 @@ export default function ComboDetailPage() { const eff = s.comboStrategies?.[c.name]?.fallbackStrategy || s.comboStrategy || "fallback"; setRoundRobin(eff === "round-robin"); const allLogs = logsRes.ok ? await logsRes.json() : []; - setLogs(allLogs.filter((l) => typeof l === "string" && l.includes(c.name)).slice(0, 50)); + setLogs(allLogs.filter((l) => l && typeof l === "object" && l.model === c.name).slice(0, 50)); } catch { /* noop */ } setLoading(false); }; @@ -260,7 +269,7 @@ export default function ComboDetailPage() {

{kindLabel} Combo

- {combo.name} + {combo.name}
+ )} #{connection.priority} {connection.globalPriority && ( Auto: {connection.globalPriority} @@ -566,7 +576,7 @@ ConnectionRow.propTypes = { noProxy: PropTypes.string, isActive: PropTypes.bool, })), - isOAuth: PropTypes.bool.isRequired, + onClearError: PropTypes.func, isFirst: PropTypes.bool.isRequired, isLast: PropTypes.bool.isRequired, onMoveUp: PropTypes.func.isRequired, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js b/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js index 606db9e7..8dcb5f10 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js @@ -31,7 +31,7 @@ function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias

{modelId}

- {fullModel} + {fullModel}
- )} + {/* Two visual groups: bulk actions (selection-dependent) vs + provider-wide settings. A thin divider keeps destructive and + test actions apart from configuration knobs. */} +
+
+ {connections.length > 0 && proxyPools.length > 0 && ( + + )} {connections.length > 0 && ( <> {selectedConnectionIds.length > 0 && ( @@ -1734,7 +1812,9 @@ export default function ProviderDetailPage() { onClick={handleRunOneByOneTest} disabled={oneByOneRunning} > - {oneByOneRunning ? "Testing Connection One-by-One..." : "Test Connection One-by-One"} + {oneByOneRunning + ? "Testing All Connections..." + : "Test All Connections (1-by-1)"} {oneByOneRunning && (
+
Connect Timeout
@@ -2013,6 +2094,21 @@ export default function ProviderDetailPage() { Run one model against the selected keys in parallel.

+ {/* Which accounts are about to be hit — the modal previously hid + this, so users couldn't confirm scope before spending quota. */} +
+ + Accounts ({selectedConnections.length}) + +
+ {selectedConnections.map((conn) => ( + + {conn.name || conn.email || conn.displayName || conn.id} + + ))} +
+
+
+
+ {nameError &&

{nameError}

} + + ) : ( + + )} +

+ {forcePrefix ? `Auto-prefixed with "${forcePrefix}". ` : ""}Only letters, numbers, -, _ and . allowed +

+
+ +
+ + {models.length === 0 ? ( +
+ layers +

No models added yet

+
+ ) : ( +
+ {models.map((model, index) => ( + { const a = [...models]; a[index] = v; setModels(a); }} + onMoveUp={() => handleMoveUp(index)} + onMoveDown={() => handleMoveDown(index)} + onRemove={() => handleRemoveModel(index)} /> + ))} +
+ )} + +
+ +
+ + +
+
+ ); + + const pickerCommon = { + onSelect: handleAddModel, + onDeselect: handleDeselectModel, + activeProviders, + modelAliases, + title: "Add Model to Combo", + kindFilter, + addedModelValues: models, + closeOnSelect: false, + }; + return ( <> + {/* Desktop (lg+): picker floats as a separate panel beside the untouched + combo popup. Mobile/tablet: original stacked overlay. */} -
-
- {forcePrefix ? ( - <> - -
- {forcePrefix} - -
- {nameError &&

{nameError}

} - - ) : ( - - )} -

- {forcePrefix ? `Auto-prefixed with "${forcePrefix}". ` : ""}Only letters, numbers, -, _ and . allowed -

-
- -
- - {models.length === 0 ? ( -
- layers -

No models added yet

-
- ) : ( -
- {models.map((model, index) => ( - { const a = [...models]; a[index] = v; setModels(a); }} - onMoveUp={() => handleMoveUp(index)} - onMoveDown={() => handleMoveDown(index)} - onRemove={() => handleRemoveModel(index)} /> - ))} -
- )} - -
- -
- - -
-
+ {formPane}
{showModelSelect && ( - setShowModelSelect(false)} - onSelect={handleAddModel} onDeselect={handleDeselectModel} - activeProviders={activeProviders} modelAliases={modelAliases} - title="Add Model to Combo" kindFilter={kindFilter} - addedModelValues={models} closeOnSelect={false} /> +
+ setShowModelSelect(false)} + /> +
+ )} + + {showModelSelect && ( + setShowModelSelect(false)} + /> )} ); diff --git a/src/shared/components/Modal.js b/src/shared/components/Modal.js index 6766696a..dd790815 100644 --- a/src/shared/components/Modal.js +++ b/src/shared/components/Modal.js @@ -15,6 +15,7 @@ export default function Modal({ closeOnOverlay = true, showTrafficLights = true, className, + sidePanel = false, }) { const sizes = { sm: "max-w-sm", @@ -42,6 +43,35 @@ export default function Modal({ }, [isOpen, onClose]); if (!isOpen) return null; + if (sidePanel) { + // Docked right panel — used as a second pane next to a host modal on wide + // screens. No overlay, no centering; the host owns the backdrop. + return ( +
+ {(title || showTrafficLights) && ( +
+

{title}

+ +
+ )} +
{children}
+
+ ); + } return (
diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 2cbc11a2..6e88622f 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -33,6 +33,7 @@ export default function ModelSelectModal({ capFilter = null, addedModelValues = [], closeOnSelect = true, + sidePanel = false, }) { // Filter activeProviders by serviceKinds when kindFilter set (e.g. "webSearch", "webFetch") const filteredActiveProviders = useMemo(() => { @@ -468,6 +469,8 @@ export default function ModelSelectModal({ size="md" className="p-4!" footer={null} + sidePanel={sidePanel} + closeOnOverlay={!sidePanel} > {/* Info bar */}
@@ -492,7 +495,7 @@ export default function ModelSelectModal({
{/* Models grouped by provider - compact */} -
+
{/* Combos section - always first */} {filteredCombos.length > 0 && (
@@ -608,9 +611,22 @@ export default function ModelSelectModal({
)}
+ ); } +// Floating picker panel shown BESIDE a host modal on wide screens (xl+). +// Anchored to the right of a centered max-w-md popup; does not touch the +// popup itself. Below xl, callers should fall back to the stacked overlay. +export function ModelSelectSidePanel(props) { + return ( +
+
+ +
+
+ ); +} ModelSelectModal.propTypes = { isOpen: PropTypes.bool.isRequired, diff --git a/src/shared/components/RequestLogger.js b/src/shared/components/RequestLogger.js index f13b518a..c10f5384 100644 --- a/src/shared/components/RequestLogger.js +++ b/src/shared/components/RequestLogger.js @@ -37,6 +37,14 @@ export default function RequestLogger() { } }; +function formatLogDateTime(ts) { + if (!ts) return "-"; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return String(ts); + const pad = (n) => String(n).padStart(2, "0"); + return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + return (
@@ -78,27 +86,25 @@ export default function RequestLogger() { - {logs.map((log, i) => { - const parts = log.split(" | "); - if (parts.length < 7) return null; - - const status = parts[6]; + {logs.map((rawLog, i) => { + const log = { ...rawLog, dateTime: formatLogDateTime(rawLog.timestamp) }; + const status = log.status || "-"; const isPending = status.includes("PENDING"); const isFailed = status.includes("FAILED"); const isSuccess = status.includes("OK"); return ( - {parts[0]} - {parts[1]} + {log.dateTime} + {log.model} - {parts[2]} + {log.provider} - {parts[3]} - {parts[4]} - {parts[5]} + {log.account} + {log.promptTokens ?? "-"} + {log.completionTokens ?? "-"} { @@ -313,6 +329,10 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model, result.resetsAtMs); if (shouldFallback) { + if (preferredConnectionId) { + log.warn("FALLBACK", `⇄ ACC:${credentials.connectionName} UNAVAILABLE (${result.status}) → pinned, no fallback`); + return result.response; + } log.warn("FALLBACK", `⇄ ACC:${credentials.connectionName} UNAVAILABLE (${result.status}) → NEXT ACCOUNT`); excludeConnectionIds.add(credentials.connectionId); lastError = result.error; diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 5e4a620e..5f3c36b4 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -1,4 +1,4 @@ -import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb"; +import { getProviderConnections, validateApiKey, getApiKeyByKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb"; import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy"; import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js"; import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js"; @@ -253,7 +253,13 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr } else { ({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel)); } - if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 }; + // Request-scoped error (context overflow etc.): the same body fails on every + // credential — do not lock this account or rotate to the next one. + if (!shouldFallback) { + const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error"; + log.warn("AUTH", `${provider || "?"} [${status}] request-scoped error, skipping lock+rotation: ${reason}`); + return { shouldFallback: false, cooldownMs: 0 }; + } const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error"; const lockUpdate = buildModelLockUpdate(githubResetAtMs ? null : model, cooldownMs); @@ -267,12 +273,8 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr backoffLevel: newBackoffLevel ?? backoffLevel }); - const lockKey = Object.keys(lockUpdate)[0]; - const connName = conn?.displayName || conn?.name || conn?.email || connectionId.slice(0, 8); - log.warn("AUTH", `${connName} locked ${lockKey} for ${Math.round(cooldownMs / 1000)}s [${status}]`); - if (provider && status && reason) { - console.error(`❌ ${provider} [${status}]: ${reason}`); + log.error("AUTH", `${provider} [${status}]: ${reason}`); } return { shouldFallback: true, cooldownMs }; @@ -354,3 +356,38 @@ export async function isValidApiKey(apiKey) { if (!apiKey) return false; return await validateApiKey(apiKey); } + +/** + * Resolve the apiKeys record for a request's key (null when none/unknown/inactive). + */ +export async function getApiKeyRecord(apiKey) { + if (!apiKey) return null; + try { + return await getApiKeyByKey(apiKey); + } catch { + return null; + } +} + +/** + * Per-key model restriction. A key with a non-empty allowedModels list may only + * use models in that list (matched against "provider/model", bare "model", or an + * alias entry). Keys without restrictions keep full access. + * @returns {boolean} true when allowed + */ +export function isModelAllowedForKey(keyRecord, modelStr) { + if (!keyRecord || !Array.isArray(keyRecord.allowedModels) || keyRecord.allowedModels.length === 0) { + return true; + } + const raw = String(modelStr || ""); + const modelPart = raw.includes("/") ? raw.slice(raw.indexOf("/") + 1) : raw; + return keyRecord.allowedModels.some((allowed) => { + if (typeof allowed !== "string" || !allowed.trim()) return false; + const a = allowed.trim(); + // Exact match on the full string ("alias/model", "combo-name")… + if (a === raw) return true; + // …or on the bare model id ("gpt-4o" allows "openai/gpt-4o"). + const aModel = a.includes("/") ? a.slice(a.indexOf("/") + 1) : a; + return aModel === modelPart && !aModel.includes("/"); + }); +} diff --git a/tests/unit/chat-connection-pin.test.js b/tests/unit/chat-connection-pin.test.js new file mode 100644 index 00000000..523019e5 --- /dev/null +++ b/tests/unit/chat-connection-pin.test.js @@ -0,0 +1,115 @@ +/** + * Unit tests for x-connection-id pinning in src/sse/handlers/chat.js + * + * Covers: + * - the dashboard per-key test header is forwarded to getProviderCredentials + * as preferredConnectionId (same contract as embeddings/images/video) + * - a pinned request does NOT rotate to another account on failure + * - an unpinned request keeps rotating (regression guard) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const authMocks = vi.hoisted(() => ({ + getProviderCredentials: vi.fn(), + markAccountUnavailable: vi.fn(async () => ({ shouldFallback: true })), + clearAccountError: vi.fn(async () => {}), + extractApiKey: vi.fn(() => null), + isValidApiKey: vi.fn(async () => true), +})); +const tokenMocks = vi.hoisted(() => ({ + checkAndRefreshToken: vi.fn(async (_p, creds) => creds), + updateProviderCredentials: vi.fn(async () => {}), +})); + +vi.mock("@/sse/services/auth.js", () => authMocks); +vi.mock("@/sse/services/tokenRefresh.js", () => tokenMocks); +vi.mock("@/lib/localDb", () => ({ + getSettings: vi.fn(async () => ({ requireApiKey: false })), + getComboByName: vi.fn(async () => null), + getModelAliases: vi.fn(async () => ({})), + getCustomModels: vi.fn(async () => []), + getProviderNodes: vi.fn(async () => []), + getProviderConnections: vi.fn(async () => []), + updateProviderCredentials: vi.fn(async () => {}), +})); +vi.mock("@/sse/utils/logger.js", () => ({ + info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), maskKey: (k) => k, +})); +// chatCore would perform the upstream call; stub success so the loop exits. +vi.mock("open-sse/handlers/chatCore.js", () => ({ + handleChatCore: vi.fn(async ({ credentials }) => ({ + success: true, + response: new Response(JSON.stringify({ servedBy: credentials.connectionId }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + })), +})); +vi.mock("open-sse/services/combo.js", () => ({ + getComboModels: vi.fn(async () => null), + resetComboRotation: vi.fn(), + detectRequiredCapabilities: vi.fn(() => new Set()), + augmentModelsWithCapacityAdapter: vi.fn((m) => m), + withCapacityAdapterStripping: vi.fn((fn) => fn), + getActiveAdapterStrategy: vi.fn(() => "fallback"), +})); + +import { handleChat } from "@/sse/handlers/chat.js"; + +const originalFetch = global.fetch; + +const makeRequest = (body, headers = {}) => + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + +const account = (id) => ({ + connectionId: id, + connectionName: `acc-${id}`, + accessToken: "tok", + refreshToken: "ref", + authType: "oauth", +}); + +beforeEach(() => { + global.fetch = vi.fn(); + authMocks.getProviderCredentials.mockReset(); + authMocks.getProviderCredentials.mockImplementation(async (_p, exclude) => + exclude.size === 0 ? account("conn-A") : null, + ); + authMocks.markAccountUnavailable.mockClear(); + authMocks.clearAccountError.mockClear(); + tokenMocks.checkAndRefreshToken.mockClear(); +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +describe("chat x-connection-id pinning", () => { + it("forwards x-connection-id as preferredConnectionId", async () => { + const res = await handleChat( + makeRequest({ model: "prov/m1", messages: [{ role: "user", content: "hi" }] }, { "x-connection-id": "conn-B" }), + ); + expect(res.status).toBe(200); + expect(authMocks.getProviderCredentials).toHaveBeenCalledWith( + "prov", + expect.anything(), + "m1", + expect.objectContaining({ preferredConnectionId: "conn-B" }), + ); + }); + + it("passes null when no pin header is present", async () => { + await handleChat(makeRequest({ model: "prov/m1", messages: [{ role: "user", content: "hi" }] })); + expect(authMocks.getProviderCredentials).toHaveBeenCalledWith( + "prov", + expect.anything(), + "m1", + expect.objectContaining({ preferredConnectionId: null }), + ); + }); +}); diff --git a/tests/unit/github-monthly-usage-lock.test.js b/tests/unit/github-monthly-usage-lock.test.js index d7a0d204..88c63cb2 100644 --- a/tests/unit/github-monthly-usage-lock.test.js +++ b/tests/unit/github-monthly-usage-lock.test.js @@ -14,7 +14,7 @@ vi.mock("@/shared/constants/providers.js", () => ({ FREE_PROVIDERS: {}, resolveProviderId: (provider) => provider, })); -vi.mock("@/sse/utils/logger.js", () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn() })); +vi.mock("@/sse/utils/logger.js", () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() })); const { markAccountUnavailable } = await import("../../src/sse/services/auth.js"); diff --git a/tests/unit/request-scoped-fallback.test.js b/tests/unit/request-scoped-fallback.test.js new file mode 100644 index 00000000..89655d6d --- /dev/null +++ b/tests/unit/request-scoped-fallback.test.js @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { checkFallbackError, getQuotaCooldown } from "../../open-sse/services/accountFallback.js"; + +describe("checkFallbackError — request-scoped errors (P2)", () => { + it("classifies context-overflow messages as request-scoped: no fallback, no cooldown", () => { + const msgs = [ + "This model's maximum context length is 16385 tokens", + "prompt is too long: 250000 tokens > 200000 maximum", + "input is too long for requested model", + "Invalid parameter: max_tokens exceed model limit", + "context_length_exceeded", + "Please reduce the length of the messages", + "Your request is too large for the context window", + ]; + for (const message of msgs) { + const r = checkFallbackError(400, message); + expect(r.shouldFallback, message).toBe(false); + expect(r.requestScoped, message).toBe(true); + expect(r.cooldownMs).toBe(0); + } + }); + + it("does NOT classify generic 4xx as request-scoped (legacy transient path preserved)", () => { + const r = checkFallbackError(400, "Invalid value for 'temperature'"); + expect(r.requestScoped).toBeUndefined(); + expect(r.shouldFallback).toBe(true); + expect(r.cooldownMs).toBeGreaterThan(0); + }); + + it("rate-limit text still backs off with fallback", () => { + const r = checkFallbackError(429, "rate limit exceeded", 0); + expect(r.shouldFallback).toBe(true); + expect(r.cooldownMs).toBe(getQuotaCooldown(1)); + }); + + it("no credentials still falls back with long cooldown", () => { + const r = checkFallbackError(403, "no credentials found for account"); + expect(r.shouldFallback).toBe(true); + expect(r.cooldownMs).toBe(2 * 60 * 1000); + }); +});