feat(dashboard): per-key model restrictions, pin header routing, combo side-panel picker

- 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.
This commit is contained in:
2026-08-27 09:26:17 +07:00
parent 1d56e2dbc5
commit f0adfb205a
42 changed files with 1028 additions and 339 deletions

View File

@@ -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

View File

@@ -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(...)`).
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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 });

View File

@@ -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";

View File

@@ -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 "";

View File

@@ -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 };

View File

@@ -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<Response>
* @param {Object} options.log - Logger object
* @returns {Promise<Response>}
*/
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" }
}
);
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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 (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEdit ? "Edit Combo" : "Create Combo"}
>
{/* Combo popup stays exactly as before on all breakpoints. */}
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? "Edit Combo" : "Create Combo"}>
<div className="flex flex-col gap-3">
{/* Name */}
<div>
@@ -845,20 +855,16 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
</div>
</Modal>
{/* Model Select Modal */}
{/* Mobile / tablet: original stacked overlay */}
{showModelSelect && (
<ModelSelectModal
isOpen={showModelSelect}
onClose={() => setShowModelSelect(false)}
onSelect={handleAddModel}
onDeselect={handleDeselectModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Add Model to Combo"
kindFilter={kindFilter}
addedModelValues={models}
closeOnSelect={false}
/>
<div className="xl:hidden">
<ModelSelectModal {...pickerCommon} isOpen={showModelSelect} />
</div>
)}
{/* Desktop (xl+): floating panel beside the popup, popup untouched */}
{showModelSelect && (
<ModelSelectSidePanel {...pickerCommon} />
)}
</>
);

View File

@@ -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) => (
<div
key={key.id}
className={`group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0 ${key.isActive === false ? "opacity-60" : ""}`}
className={`group flex flex-wrap items-center justify-between gap-y-2 py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0 ${key.isActive === false ? "opacity-60" : ""}`}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{key.name}</p>
<p className="text-sm font-medium break-words">{key.name}</p>
<div className="flex items-center gap-2 mt-1">
<code className="text-xs text-text-muted font-mono">
<code className="text-xs text-text-muted font-mono break-all whitespace-pre-wrap min-w-0">
{visibleKeys.has(key.id) ? key.key : maskKey(key.key)}
</code>
<button
onClick={() => toggleKeyVisibility(key.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-all"
className="p-1 shrink-0 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-all"
title={visibleKeys.has(key.id) ? "Hide key" : "Show key"}
>
<span className="material-symbols-outlined text-[14px]">
@@ -1050,7 +1086,7 @@ export default function APIPageClient({ machineId }) {
</button>
<button
onClick={() => copy(key.key, key.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-all"
className="p-1 shrink-0 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-all"
>
<span className="material-symbols-outlined text-[14px]">
{copied === key.id ? "check" : "content_copy"}
@@ -1059,12 +1095,17 @@ export default function APIPageClient({ machineId }) {
</div>
<p className="text-xs text-text-muted mt-1">
Created {new Date(key.createdAt).toLocaleDateString()}
{Array.isArray(key.allowedModels) && key.allowedModels.length > 0 && (
<span className="ml-2 text-primary">
· {key.allowedModels.length} model{key.allowedModels.length > 1 ? "s" : ""} allowed
</span>
)}
</p>
{key.isActive === false && (
<p className="text-xs text-orange-500 mt-1">Paused</p>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<Toggle
size="sm"
checked={key.isActive ?? true}
@@ -1084,6 +1125,13 @@ export default function APIPageClient({ machineId }) {
}}
title={key.isActive ? "Pause key" : "Resume key"}
/>
<button
onClick={() => openRestrictModal(key)}
className="p-2 hover:bg-primary/10 rounded text-text-muted hover:text-primary opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
title="Restrict models for this key"
>
<span className="material-symbols-outlined text-[18px]">tune</span>
</button>
<button
onClick={() => handleDeleteKey(key.id)}
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
@@ -1225,6 +1273,105 @@ export default function APIPageClient({ machineId }) {
</div>
</Modal>
{/* Restrict Models Modal */}
<Modal
isOpen={!!restrictKey}
title="Restrict Models"
onClose={() => setRestrictKey(null)}
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between pb-3 border-b border-border">
<div>
<p className="font-medium text-sm">Full access</p>
<p className="text-xs text-text-muted mt-0.5">
{restrictSet.size > 0
? `Limited to ${restrictSet.size} model${restrictSet.size > 1 ? "s" : ""}`
: "This key can use every model and combo"}
</p>
</div>
<Toggle
checked={restrictSet.size === 0}
onChange={(checked) => {
if (checked) setRestrictSet(new Set());
}}
title={restrictSet.size === 0 ? "Full access on" : "Restricted"}
/>
</div>
{restrictSet.size > 0 && (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5">
{[...restrictSet].map((m) => (
<span key={m} className="inline-flex items-center gap-1 px-2 py-1 rounded-xl text-xs font-medium bg-primary/10 text-primary border border-primary/20">
{m}
<button
onClick={() => setRestrictSet((prev) => {
const next = new Set(prev);
next.delete(m);
return next;
})}
className="hover:text-red-500"
title="Remove"
>
<span className="material-symbols-outlined leading-none" style={{ fontSize: "12px" }}>close</span>
</button>
</span>
))}
</div>
<Button variant="secondary" icon="tune" size="sm" onClick={() => setShowModelPicker(true)}>
Choose models
</Button>
</div>
)}
{restrictSet.size === 0 && (
<Button variant="secondary" icon="tune" onClick={() => setShowModelPicker(true)}>
Choose models to allow
</Button>
)}
<p className="text-xs text-text-muted">
Restricted keys get an error when calling other models, and /v1/models only lists the models above.
</p>
<div className="flex gap-2">
<Button onClick={() => persistRestriction(restrictKey.id, [...restrictSet])} fullWidth disabled={restrictSaving}>
{restrictSaving ? "Saving..." : "Save"}
</Button>
<Button onClick={() => setRestrictKey(null)} variant="ghost" fullWidth disabled={restrictSaving}>
Cancel
</Button>
</div>
</div>
</Modal>
{/* Per-key model picker (multi-select) */}
<ModelSelectModal
isOpen={showModelPicker}
onClose={() => 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 */}
<Modal
isOpen={!!createdKey}

View File

@@ -44,6 +44,15 @@ function getListingHref(kind) {
return `/dashboard/media-providers/${kind}`;
}
// Format ISO timestamp → "DD-MM-YYYY HH:MM:SS" for the usage-log table
function fmtLogTime(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())}`;
}
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() {
</div>
<div className="min-w-0">
<p className="text-xs text-text-muted">{kindLabel} Combo</p>
<code className="text-lg font-semibold font-mono">{combo.name}</code>
<code className="text-lg font-semibold font-mono break-all">{combo.name}</code>
</div>
</div>
<Button variant="outline" icon="delete" onClick={handleDelete} className="text-red-500 border-red-200 hover:bg-red-50">
@@ -396,7 +405,7 @@ export default function ComboDetailPage() {
<p className="text-xs text-text-muted italic">No usage yet.</p>
) : (
<pre className="text-[11px] font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-auto max-h-[400px] whitespace-pre-wrap">
{logs.join("\n")}
{logs.map((l) => `${fmtLogTime(l.timestamp)} | ${l.model} | ${l.provider} | ${l.account} | ${l.promptTokens ?? "-"} | ${l.completionTokens ?? "-"} | ${l.status || "-"}`).join("\n")}
</pre>
)}
</Card>

View File

@@ -28,7 +28,7 @@ function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias,
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{modelId}</p>
<div className="flex items-center gap-1 mt-1">
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
<code className="min-w-0 break-all text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
<div className="relative group/btn">
<button
onClick={() => onCopy(fullModel, `model-${modelId}`)}

View File

@@ -19,6 +19,7 @@ export default function ConnectionRow({
onUpdateProxy,
onEdit,
onDelete,
onClearError = null,
oneByOneStatus = null,
autoPing = null,
testModels = [],
@@ -263,6 +264,15 @@ export default function ConnectionRow({
{connection.lastError}
</span>
)}
{connection.lastError && onClearError && (
<button
onClick={onClearError}
className="shrink-0 rounded p-0.5 text-text-muted transition-colors hover:bg-black/5 hover:text-primary dark:hover:bg-white/5"
title="Clear this error"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
<span className="text-xs text-text-muted">#{connection.priority}</span>
{connection.globalPriority && (
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
@@ -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,

View File

@@ -31,7 +31,7 @@ function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias
<p className="text-sm font-medium truncate">{modelId}</p>
<div className="flex items-center gap-1 mt-1">
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
<code className="min-w-0 break-all text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
<div className="relative group/btn">
<button
onClick={() => onCopy(fullModel, `model-${modelId}`)}

View File

@@ -382,9 +382,12 @@ export default function ProviderDetailPage() {
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
const current = settingsData.providerStrategies || {};
// Build override: null strategy means remove override, use global
const override = {};
// Merge into the existing override — never drop sibling keys such as
// enabled:false, which is the free-provider kill switch.
const prev = current[providerId] || {};
const override = { ...prev };
if (strategy) override.fallbackStrategy = strategy;
else delete override.fallbackStrategy;
if (strategy === "round-robin" && stickyLimit !== "") {
override.stickyRoundRobinLimit = Number(stickyLimit) || 3;
}
@@ -676,10 +679,14 @@ export default function ProviderDetailPage() {
};
const handleRunOneByOneTest = async () => {
if (oneByOneRunning || connections.length === 0) return;
if (oneByOneRunning) return;
// Same population as Test Selected / routing itself: only active
// connections. Disabled keys are skipped, not tested-and-failed.
const targets = connections.filter((conn) => conn.isActive !== false);
if (targets.length === 0) return;
const queuedState = Object.fromEntries(
connections.map((connection) => [connection.id, { state: "queued", error: null }]),
targets.map((connection) => [connection.id, { state: "queued", error: null }]),
);
stopOneByOneRef.current = false;
@@ -687,16 +694,16 @@ export default function ProviderDetailPage() {
setOneByOneStopping(false);
setOneByOneCurrentConnectionId(null);
setOneByOneResults(queuedState);
setOneByOneSummary({ total: connections.length, completed: 0, passed: 0, failed: 0, stopped: false });
setOneByOneSummary({ total: targets.length, completed: 0, passed: 0, failed: 0, stopped: false });
let passed = 0;
let failed = 0;
try {
for (let index = 0; index < connections.length; index += 1) {
for (let index = 0; index < targets.length; index += 1) {
if (stopOneByOneRef.current) {
setOneByOneSummary({
total: connections.length,
total: targets.length,
completed: index,
passed,
failed,
@@ -705,7 +712,7 @@ export default function ProviderDetailPage() {
break;
}
const connection = connections[index];
const connection = targets[index];
setOneByOneCurrentConnectionId(connection.id);
setOneByOneResults((prev) => ({
...prev,
@@ -742,14 +749,14 @@ export default function ProviderDetailPage() {
}
setOneByOneSummary({
total: connections.length,
total: targets.length,
completed: index + 1,
passed,
failed,
stopped: false,
});
if (index < connections.length - 1) {
if (index < targets.length - 1) {
await sleep(ONE_BY_ONE_DELAY_MS);
}
}
@@ -767,14 +774,16 @@ export default function ProviderDetailPage() {
setOneByOneStopping(true);
};
const handleTestAllKeys = async () => {
const handleTestAllKeys = async (targetIds) => {
if (testAllKeysRunning) return;
const modelId = testAllKeysModelId.trim();
if (!modelId) {
setTestAllKeysError("Select a model to test");
return;
}
const targets = connections.filter((conn) => selectedConnectionIds.includes(conn.id));
const targets = connections.filter((conn) =>
(targetIds || selectedConnectionIds).includes(conn.id),
);
if (targets.length === 0) {
setTestAllKeysError("No connections selected");
return;
@@ -791,34 +800,76 @@ export default function ProviderDetailPage() {
})));
const fullModel = `${providerStorageAlias}/${modelId}`;
const settled = await Promise.allSettled(
targets.map(async (conn) => {
// Each request updates its own result row as it settles, so the list
// fills in live instead of all at once at the end.
const settleOne = async (target) => {
let outcome;
try {
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: fullModel, connectionId: conn.id }),
body: JSON.stringify({ model: fullModel, connectionId: target.id }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return { ok: false, latencyMs: data.latencyMs, error: data.error || `HTTP ${res.status}` };
}
return { ok: !!data.ok, latencyMs: data.latencyMs, error: data.ok ? null : (data.error || "Test failed") };
}),
);
setTestAllKeysResults(settled.map((result, index) => {
const base = {
connectionId: targets[index].id,
name: targets[index].name || targets[index].email || targets[index].id,
};
if (result.status === "fulfilled") {
return { ...base, ok: result.value.ok, latencyMs: result.value.latencyMs, error: result.value.error };
outcome = !res.ok
? { ok: false, latencyMs: data.latencyMs, error: data.error || `HTTP ${res.status}` }
: { ok: !!data.ok, latencyMs: data.latencyMs, error: data.ok ? null : (data.error || "Test failed") };
} catch (error) {
outcome = { ok: false, latencyMs: null, error: error?.message || "Network error" };
}
return { ...base, ok: false, latencyMs: null, error: result.reason?.message || "Network error" };
}));
setTestAllKeysResults((prev) =>
prev.map((r) =>
r.connectionId === target.id ? { ...r, ...outcome } : r,
),
);
};
await Promise.allSettled(targets.map(settleOne));
setTestAllKeysRunning(false);
};
// After a batch run: disable every account that failed so it drops out of
// routing. Writes the PATCHes directly — reusing handleBulkToggleActive would
// read a stale selectedConnectionIds closure and no-op.
const handleDisableFailedKeys = async () => {
const failedIds = testAllKeysResults
.filter((r) => r.ok === false)
.map((r) => r.connectionId);
if (failedIds.length === 0) return;
await Promise.allSettled(
failedIds.map((id) =>
fetch(`/api/providers/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: false }),
}).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}),
),
);
setConnections((prev) =>
prev.map((c) => (failedIds.includes(c.id) ? { ...c, isActive: false } : c)),
);
setSelectedConnectionIds((prev) =>
prev.filter((id) => !failedIds.includes(id)),
);
setTestAllKeysResults((prev) =>
prev.map((r) =>
failedIds.includes(r.connectionId) ? { ...r, ok: false, disabled: true } : r,
),
);
};
// Re-run just the accounts that failed, keeping already-passing rows as-is.
const handleRetryFailedKeys = () => {
const failedIds = testAllKeysResults
.filter((r) => r.ok === false)
.map((r) => r.connectionId);
if (failedIds.length === 0) return;
handleTestAllKeys(failedIds);
};
const handleDelete = async (id) => {
setConfirmState({
title: "Delete Connection",
@@ -955,6 +1006,28 @@ export default function ProviderDetailPage() {
}
};
// Dismiss a stale error banner. Only clears the client-visible fields;
// server truth (lastError) is cleared too so it doesn't re-appear on reload.
const handleClearConnectionError = async (id) => {
const clearOne = async (connId) => {
try {
await fetch(`/api/providers/${connId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lastError: null, lastErrorAt: null }),
});
} catch (error) {
console.log("Error clearing connection error:", error);
}
};
await clearOne(id);
setConnections((prev) =>
prev.map((c) =>
c.id === id ? { ...c, lastError: null, lastErrorAt: null, errorCode: null } : c,
),
);
};
const handleSwapPriority = async (index1, index2) => {
// Optimistic update state
const newConnections = [...connections];
@@ -1145,6 +1218,7 @@ export default function ProviderDetailPage() {
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
onClearError={conn.lastError ? () => handleClearConnectionError(conn.id) : undefined}
oneByOneStatus={oneByOneResults[conn.id] || null}
testModels={availableModels}
providerAlias={providerStorageAlias}
@@ -1678,17 +1752,21 @@ export default function ProviderDetailPage() {
<Card>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Connections</h2>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
{connections.length > 0 && proxyPools.length > 0 && (
<Button
size="sm"
variant="secondary"
icon="lan"
onClick={() => setShowBulkProxyModal(true)}
>
Apply Proxy
</Button>
)}
{/* Two visual groups: bulk actions (selection-dependent) vs
provider-wide settings. A thin divider keeps destructive and
test actions apart from configuration knobs. */}
<div className="flex min-w-0 flex-wrap items-center gap-2 sm:gap-3">
<div className="flex flex-wrap items-center gap-2">
{connections.length > 0 && proxyPools.length > 0 && (
<Button
size="sm"
variant="secondary"
icon="lan"
onClick={() => setShowBulkProxyModal(true)}
>
Apply Proxy
</Button>
)}
{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)"}
</Button>
{oneByOneRunning && (
<Button
@@ -1749,7 +1829,8 @@ export default function ProviderDetailPage() {
)}
</>
)}
{/* Connect Timeout */}
</div>
<div className="mx-1 hidden h-6 w-px bg-border sm:block" />
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-text-muted font-medium">Connect Timeout</span>
<div className="flex items-center gap-1.5">
@@ -2013,6 +2094,21 @@ export default function ProviderDetailPage() {
Run one model against the selected keys in parallel.
</p>
{/* Which accounts are about to be hit — the modal previously hid
this, so users couldn't confirm scope before spending quota. */}
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-text-muted">
Accounts ({selectedConnections.length})
</span>
<div className="flex max-h-24 flex-col gap-0.5 overflow-y-auto rounded-lg border border-border bg-black/[0.02] px-2.5 py-1.5 dark:bg-white/[0.03]">
{selectedConnections.map((conn) => (
<span key={conn.id} className="shrink-0 truncate text-xs text-text-main">
{conn.name || conn.email || conn.displayName || conn.id}
</span>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-text-muted">Model</label>
<select
@@ -2042,22 +2138,34 @@ export default function ProviderDetailPage() {
<div
key={result.connectionId}
className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-sm ${
result.ok === true
? "border-green-300 bg-green-500/10 text-green-700 dark:border-green-800 dark:text-green-400"
: result.ok === false
? "border-red-300 bg-red-500/10 text-red-600 dark:border-red-800 dark:text-red-400"
: "border-border bg-black/[0.02] text-text-muted dark:bg-white/[0.03]"
result.disabled
? "border-border bg-black/[0.02] text-text-muted dark:bg-white/[0.03]"
: result.ok === true
? "border-green-300 bg-green-500/10 text-green-700 dark:border-green-800 dark:text-green-400"
: result.ok === false
? "border-red-300 bg-red-500/10 text-red-600 dark:border-red-800 dark:text-red-400"
: "border-border bg-black/[0.02] text-text-muted dark:bg-white/[0.03]"
}`}
>
<span className="material-symbols-outlined shrink-0 text-[16px]">
{result.ok === true ? "check_circle" : result.ok === false ? "error" : "hourglass_empty"}
{result.disabled
? "toggle_off"
: result.ok === true
? "check_circle"
: result.ok === false
? "error"
: "hourglass_empty"}
</span>
<div className="min-w-0 flex-1 break-words">
<p className="font-medium truncate">
{result.name}
{typeof result.latencyMs === "number" && result.ok !== null ? ` · ${result.latencyMs}ms` : ""}
{result.disabled
? " · disabled"
: typeof result.latencyMs === "number" && result.ok !== null
? ` · ${result.latencyMs}ms`
: ""}
</p>
{result.ok === false && result.error && (
{result.ok === false && !result.disabled && result.error && (
<p className="mt-0.5 text-xs opacity-90 break-words">{result.error}</p>
)}
</div>
@@ -2066,9 +2174,36 @@ export default function ProviderDetailPage() {
</div>
)}
{(() => {
const failedCount = testAllKeysResults.filter((r) => r.ok === false && !r.disabled).length;
if (failedCount === 0) return null;
return (
<div className="flex flex-wrap gap-2">
<Button
variant="danger"
icon="toggle_off"
onClick={handleDisableFailedKeys}
disabled={testAllKeysRunning}
className="flex-1"
>
Disable Failed ({failedCount})
</Button>
<Button
variant="secondary"
icon="refresh"
onClick={handleRetryFailedKeys}
disabled={testAllKeysRunning}
className="flex-1"
>
Retry Failed ({failedCount})
</Button>
</div>
);
})()}
<div className="flex gap-2">
<Button
onClick={handleTestAllKeys}
onClick={() => handleTestAllKeys()}
disabled={!testAllKeysModelId || testAllKeysRunning}
loading={testAllKeysRunning}
icon="science"

View File

@@ -332,8 +332,11 @@ export default function ConnectionsCard({ providerId, isOAuth }) {
const res = await fetch("/api/settings", { cache: "no-store" });
const data = res.ok ? await res.json() : {};
const current = data.providerStrategies || {};
const override = {};
// Merge into the existing override — never drop sibling keys such as
// enabled:false (free-provider kill switch) or proxyPoolId.
const override = { ...(current[providerId] || {}) };
if (strategy) override.fallbackStrategy = strategy;
else delete override.fallbackStrategy;
if (strategy === "round-robin" && stickyLimit !== "") override.stickyRoundRobinLimit = Number(stickyLimit) || 3;
const updated = { ...current };
if (Object.keys(override).length === 0) delete updated[providerId];

View File

@@ -15,11 +15,11 @@ export function ModelRow({ model, fullModel, copied, onCopy, testStatus, isCusto
return (
<div className={`group px-3 py-2 rounded-lg border ${borderColor} hover:bg-sidebar/50`}>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-base" style={iconColor ? { color: iconColor } : undefined}>
<span className="material-symbols-outlined text-base shrink-0" style={iconColor ? { color: iconColor } : undefined}>
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
</span>
<div className="flex flex-col gap-1">
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
<div className="flex flex-col gap-1 min-w-0">
<code className="break-all text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
{model.name && <span className="text-[9px] text-text-muted/70 italic pl-1">{model.name}</span>}
</div>
{onTest && (

View File

@@ -224,15 +224,28 @@ export default function ProvidersPage() {
setConnections((prev) =>
prev.map((c) => (matches(c) ? { ...c, isActive: newActive } : c)),
);
await Promise.allSettled(
const settled = await Promise.allSettled(
providerConns.map((c) =>
fetch(`/api/providers/${c.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: newActive }),
}).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}),
),
);
const failedCount = settled.filter((r) => r.status === "rejected").length;
if (failedCount > 0) {
// Re-fetch so the UI reflects what actually persisted.
notify.error(`Toggle failed for ${failedCount} connection(s)`);
fetch("/api/providers", { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.connections) setConnections(data.connections);
})
.catch(() => {});
}
};
// Toggle a free provider (noAuth or zero-connection) via providerStrategies.enabled.
@@ -244,13 +257,22 @@ export default function ProvidersPage() {
const override = { ...(allStrategies[providerId] || {}), enabled: newActive };
allStrategies[providerId] = override;
setProviderStrategies(allStrategies);
await fetch("/api/settings", {
const patchRes = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerStrategies: allStrategies }),
});
if (!patchRes.ok) throw new Error(`HTTP ${patchRes.status}`);
} catch (error) {
console.log("Error toggling noAuth provider:", error);
notify.error("Failed to update provider — try again");
// Re-sync from server so the toggle reflects persisted state.
fetch("/api/settings", { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data) setProviderStrategies(data.providerStrategies || {});
})
.catch(() => {});
}
};
@@ -682,12 +704,12 @@ export default function ProvidersPage() {
function ProviderCard({ providerId, provider, stats, authType, onToggle, providerStrategies, onToggleNoAuth, isFree }) {
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const isNoAuth = !!provider.noAuth;
const isNoAuthDisabled = isNoAuth && providerStrategies?.[providerId]?.enabled === false;
const effectiveDisabled = allDisabled || isNoAuthDisabled;
// Free providers without real connections (noAuth, or free OAuth like gemini-cli
// with zero connections) toggle via providerStrategies.enabled.
// Free providers without real connections (noAuth, or free OAuth like
// gemini-cli / kiro with zero connections) toggle via providerStrategies.enabled.
const usesNoAuthToggle = isNoAuth || (isFree && stats.total === 0);
const strategyDisabled =
usesNoAuthToggle && providerStrategies?.[providerId]?.enabled === false;
const effectiveDisabled = allDisabled || strategyDisabled;
const handleToggleClick = () => {
if (usesNoAuthToggle) {
@@ -749,6 +771,8 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle, provide
</Badge>
) : isNoAuth ? (
<Badge variant="success" size="sm" dot>Ready</Badge>
) : usesNoAuthToggle && stats.total === 0 ? (
<Badge variant="success" size="sm" dot>Free · Connect</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
@@ -763,7 +787,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle, provide
<div className="flex shrink-0 items-center gap-2">
{(stats.total > 0 || usesNoAuthToggle) && (
<div
className="opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
className="opacity-100"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -907,7 +931,7 @@ function ApiKeyProviderCard({
<div className="flex shrink-0 items-center gap-2">
{stats.total > 0 && (
<div
className="opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
className="opacity-100"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();

View File

@@ -21,7 +21,8 @@ export async function PUT(request, { params }) {
try {
const { id } = await params;
const body = await request.json();
const { isActive } = body;
const { isActive, allowedModels } = body;
const existing = await getApiKeyById(id);
if (!existing) {
@@ -30,6 +31,12 @@ export async function PUT(request, { params }) {
const updateData = {};
if (isActive !== undefined) updateData.isActive = isActive;
// null / [] clears the restriction (full access)
if (allowedModels !== undefined) {
updateData.allowedModels = Array.isArray(allowedModels)
? allowedModels.map((m) => String(m).trim()).filter(Boolean)
: null;
}
const updated = await updateApiKey(id, updateData);

View File

@@ -2,13 +2,20 @@ import { NextResponse } from "next/server";
import { getProviderConnections } from "@/models";
import {
FREE_PROVIDERS,
FREE_TIER_PROVIDERS,
OAUTH_PROVIDERS,
APIKEY_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
import { testSingleConnection } from "../[id]/test/testUtils.js";
function isCompatibleProvider(providerId) {
return (
typeof providerId === "string" &&
(providerId.startsWith(OPENAI_COMPATIBLE_PREFIX) || providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
);
}
function getAuthGroup(providerId, connection = null) {
// Prioritize authType from connection if available
if (connection?.authType) {
@@ -17,28 +24,20 @@ function getAuthGroup(providerId, connection = null) {
if (FREE_PROVIDERS[providerId]) return "free";
return "oauth";
}
// freeTier providers keep their apikey connections in the "free" group so
// the Free Tier section's Test All covers them (matches the grid cards).
if (FREE_TIER_PROVIDERS[providerId]) return "free";
return connection.authType;
}
// Fallback to constants
if (FREE_PROVIDERS[providerId]) return "free";
// Fallback to constants — freeTier providers with apikey connections count
// as "free" here so the section Test All buttons match what the grid shows.
if (FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId]) return "free";
if (OAUTH_PROVIDERS[providerId]) return "oauth";
if (APIKEY_PROVIDERS[providerId]) return "apikey";
if (
typeof providerId === "string" &&
(providerId.startsWith(OPENAI_COMPATIBLE_PREFIX) || providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
)
return "compatible";
if (isCompatibleProvider(providerId)) return "compatible";
return "apikey";
}
function isCompatibleProvider(providerId) {
return (
typeof providerId === "string" &&
(providerId.startsWith(OPENAI_COMPATIBLE_PREFIX) || providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
);
}
// POST /api/providers/test-batch - Test multiple connections by group
export async function POST(request) {
try {

View File

@@ -6,6 +6,7 @@ import {
isOpenAICompatibleProvider,
} from "@/shared/constants/providers";
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
import { getApiKeyRecord } from "@/sse/services/auth.js";
import { getDisabledModels } from "@/lib/disabledModelsDb";
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
@@ -246,6 +247,8 @@ export async function buildModelsList(kindFilter, options = {}) {
// 9router instance's fetchCompatibleModelIds — skip dynamic fetch to break
// cross-instance recursive loops.
const skipDynamicFetch = options.skipDynamicFetch === true;
// Optional per-key model restriction (apiKeys.allowedModels).
const keyRecord = options.keyRecord || null;
let connections = [];
try {
connections = await getProviderConnections();
@@ -530,10 +533,22 @@ export async function buildModelsList(kindFilter, options = {}) {
}
}
const restricted = Array.isArray(keyRecord?.allowedModels) && keyRecord.allowedModels.length > 0;
const allowed = restricted ? new Set(keyRecord.allowedModels.map((m) => String(m).trim()).filter(Boolean)) : null;
const bareAllowed = restricted
? new Set([...allowed].map((m) => (m.includes("/") ? m.slice(m.indexOf("/") + 1) : m)))
: null;
const dedupedModels = [];
const seenModelIds = new Set();
for (const model of models) {
if (!model?.id || seenModelIds.has(model.id)) continue;
// Per-key visibility: a restricted key only sees its allowed models
// (full "alias/model" id or the bare model id after the slash).
if (restricted) {
const bare = model.id.includes("/") ? model.id.slice(model.id.indexOf("/") + 1) : model.id;
if (!allowed.has(model.id) && !bareAllowed.has(bare)) continue;
}
seenModelIds.add(model.id);
dedupedModels.push(model);
}
@@ -562,7 +577,10 @@ export async function GET(request) {
try {
// Detect cross-instance recursive /models fetch (another 9router fetching our /models)
const skipDynamicFetch = request?.headers?.get(INTERNAL_MODELS_FETCH_HEADER) === "1";
const data = await buildModelsList([LLM_KIND], { skipDynamicFetch });
// Restrict visibility when the caller authenticates with a restricted API key
const { extractApiKey, getApiKeyRecord } = await import("@/sse/services/auth.js");
const keyRecord = request ? await getApiKeyRecord(extractApiKey(request)) : null;
const data = await buildModelsList([LLM_KIND], { skipDynamicFetch, keyRecord });
return Response.json({ object: "list", data }, {
headers: { "Access-Control-Allow-Origin": "*" },
});

View File

@@ -29,7 +29,7 @@ export {
// API keys
export {
getApiKeys, getApiKeyById, createApiKey, updateApiKey, importApiKey, deleteApiKey, validateApiKey,
getApiKeys, getApiKeyById, createApiKey, updateApiKey, importApiKey, deleteApiKey, validateApiKey, getApiKeyByKey,
} from "./repos/apiKeysRepo.js";
// Combos
@@ -59,7 +59,7 @@ export {
export {
statsEmitter, trackPendingRequest, getActiveRequests,
saveRequestUsage, getUsageHistory, getUsageStats, getChartData,
appendRequestLog, getRecentLogs,
getRecentLogs,
} from "./repos/usageRepo.js";
// Request details

View File

@@ -1,3 +1,4 @@
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
import { v4 as uuidv4 } from "uuid";
import { getAdapter } from "../driver.js";
@@ -9,6 +10,7 @@ function rowToKey(row) {
name: row.name,
machineId: row.machineId,
isActive: row.isActive === 1 || row.isActive === true,
allowedModels: parseJson(row.allowedModels, null),
createdAt: row.createdAt,
};
}
@@ -53,8 +55,9 @@ export async function updateApiKey(id, data) {
if (!row) return;
const merged = { ...rowToKey(row), ...data };
db.run(
`UPDATE apiKeys SET key = ?, name = ?, machineId = ?, isActive = ? WHERE id = ?`,
[merged.key, merged.name, merged.machineId, merged.isActive ? 1 : 0, id]
`UPDATE apiKeys SET key = ?, name = ?, machineId = ?, isActive = ?, allowedModels = ? WHERE id = ?`,
[merged.key, merged.name, merged.machineId, merged.isActive ? 1 : 0,
merged.allowedModels == null ? null : stringifyJson(merged.allowedModels), id]
);
result = merged;
});
@@ -98,3 +101,14 @@ export async function validateApiKey(key) {
if (!row) return false;
return row.isActive === 1 || row.isActive === true;
}
// Resolve a raw key string to its full record (null when unknown/inactive).
// Used by the gateway to enforce per-key model restrictions.
export async function getApiKeyByKey(key) {
if (!key) return null;
const db = await getAdapter();
const row = db.get(`SELECT * FROM apiKeys WHERE key = ?`, [key]);
const rec = rowToKey(row);
if (!rec || !rec.isActive) return null;
return rec;
}

View File

@@ -1105,19 +1105,11 @@ export async function getChartData(period = "7d") {
});
}
function formatLogDate(date = new Date()) {
const pad = (n) => String(n).padStart(2, "0");
return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
// No-op: request log is now derived from usageHistory table on read.
export async function appendRequestLog() {}
export async function getRecentLogs(limit = 200) {
try {
const db = await getAdapter();
const rows = db.all(
`SELECT timestamp, provider, model, connectionId, promptTokens, completionTokens, status, tokens FROM usageHistory ORDER BY id DESC LIMIT ?`,
`SELECT timestamp, provider, model, connectionId, promptTokens, completionTokens, status FROM usageHistory ORDER BY id DESC LIMIT ?`,
[limit],
);
if (!rows.length) return [];
@@ -1126,21 +1118,18 @@ export async function getRecentLogs(limit = 200) {
try {
const { getProviderConnections } = await import("./connectionsRepo.js");
const connections = await getProviderConnections();
for (const c of connections) connMap[c.id] = c.name || c.email || "";
for (const c of connections) connMap[c.id] = c.displayName || c.name || c.email || "";
} catch {}
return rows.map((r) => {
const ts = formatLogDate(new Date(r.timestamp));
const p = r.provider?.toUpperCase() || "-";
const m = r.model || "-";
const account =
connMap[r.connectionId] ||
(r.connectionId ? r.connectionId.slice(0, 8) : "-");
const tk = r.tokens ? parseJson(r.tokens, {}) : {};
const sent = r.promptTokens ?? tk.prompt_tokens ?? "-";
const received = r.completionTokens ?? tk.completion_tokens ?? "-";
return `${ts} | ${m} | ${p} | ${account} | ${sent} | ${received} | ${r.status || "-"}`;
});
return rows.map((r) => ({
timestamp: r.timestamp,
model: r.model || "-",
provider: r.provider?.toUpperCase() || "-",
account: connMap[r.connectionId] || (r.connectionId ? r.connectionId.slice(0, 8) : "-"),
promptTokens: r.promptTokens ?? null,
completionTokens: r.completionTokens ?? null,
status: r.status || "-",
}));
} catch (e) {
console.error("[usageRepo] getRecentLogs failed:", e.message);
return [];

View File

@@ -3,7 +3,7 @@
// pre-change safety backup in migrate.js: when the stored version is lower,
// one lightweight DB backup is taken before applying schema changes. Forgetting
// to bump only skips that backup — it does NOT break the additive auto-sync.
export const SCHEMA_VERSION = 2;
export const SCHEMA_VERSION = 3;
export const PRAGMA_SQL = `
PRAGMA journal_mode = WAL;
@@ -82,7 +82,7 @@ export const TABLES = {
name: "TEXT",
machineId: "TEXT",
isActive: "INTEGER DEFAULT 1",
createdAt: "TEXT NOT NULL",
allowedModels: "TEXT",
},
indexes: ["CREATE INDEX IF NOT EXISTS idx_ak_key ON apiKeys(key)"],
},

View File

@@ -10,7 +10,7 @@ export {
createProviderNode, updateProviderNode, deleteProviderNode,
getProxyPools, getProxyPoolById,
createProxyPool, updateProxyPool, deleteProxyPool,
getApiKeys, getApiKeyById, createApiKey, updateApiKey, importApiKey, deleteApiKey, validateApiKey,
getApiKeys, getApiKeyById, createApiKey, updateApiKey, importApiKey, deleteApiKey, validateApiKey, getApiKeyByKey,
getCombos, getComboById, getComboByName,
createCombo, updateCombo, deleteCombo,
getModelAliases, setModelAlias, deleteModelAlias,

View File

@@ -2,7 +2,7 @@
export {
statsEmitter, trackPendingRequest, getActiveRequests,
saveRequestUsage, getUsageHistory, getUsageStats, getChartData,
appendRequestLog, getRecentLogs,
getRecentLogs,
saveRequestDetail, getRequestDetails, getRequestDetailById,
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
} from "@/lib/db/index.js";

View File

@@ -999,7 +999,7 @@ export default function ApiExplorerModal({ isOpen, onClose, initialEndpointId =
<span className={cn("text-[10px] font-bold px-1.5 py-0.5 rounded", methodBadgeClass(endpoint.method))}>
{endpoint.method}
</span>
<code className="text-xs font-mono text-text-muted">{endpoint.path}</code>
<code className="text-xs font-mono text-text-muted break-all">{endpoint.path}</code>
</div>
<p className="text-sm text-text-muted mt-1">{endpoint.description}</p>
</div>

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import Modal from "./Modal";
import Input from "./Input";
import Button from "./Button";
import ModelSelectModal from "./ModelSelectModal";
import ModelSelectModal, { ModelSelectSidePanel } from "./ModelSelectModal";
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
@@ -108,70 +108,96 @@ export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeP
const isEdit = !!combo;
const formPane = (
<div className="flex flex-col gap-3">
<div>
{forcePrefix ? (
<>
<label className="text-sm font-medium mb-1 block">Combo Name</label>
<div className="flex items-stretch">
<span className="inline-flex items-center px-2 rounded-l border border-r-0 border-black/10 dark:border-white/10 bg-black/[0.04] dark:bg-white/[0.04] text-text-muted font-mono text-sm">{forcePrefix}</span>
<input value={name} onChange={handleNameChange} placeholder="my-combo"
className="flex-1 min-w-0 rounded-r border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 px-2 py-1.5 font-mono text-sm outline-none focus:border-primary" />
</div>
{nameError && <p className="text-[11px] text-red-500 mt-0.5">{nameError}</p>}
</>
) : (
<Input label="Combo Name" value={name} onChange={handleNameChange} placeholder="my-combo" error={nameError} />
)}
<p className="text-[10px] text-text-muted mt-0.5">
{forcePrefix ? `Auto-prefixed with "${forcePrefix}". ` : ""}Only letters, numbers, -, _ and . allowed
</p>
</div>
<div>
<label className="text-sm font-medium mb-1.5 block">Models</label>
{models.length === 0 ? (
<div className="text-center py-4 border border-dashed border-black/10 dark:border-white/10 rounded-lg bg-black/[0.01] dark:bg-white/[0.01]">
<span className="material-symbols-outlined text-text-muted text-xl mb-1">layers</span>
<p className="text-xs text-text-muted">No models added yet</p>
</div>
) : (
<div className="flex max-h-[55vh] min-w-0 flex-col gap-1 overflow-y-auto sm:max-h-[350px] lg:max-h-[420px]">
{models.map((model, index) => (
<ModelItem key={index} index={index} model={model}
isFirst={index === 0} isLast={index === models.length - 1}
onEdit={(v) => { const a = [...models]; a[index] = v; setModels(a); }}
onMoveUp={() => handleMoveUp(index)}
onMoveDown={() => handleMoveDown(index)}
onRemove={() => handleRemoveModel(index)} />
))}
</div>
)}
<button onClick={() => setShowModelSelect(true)}
className="w-full mt-2 py-2 border border-dashed border-black/10 dark:border-white/10 rounded-lg text-xs text-primary font-medium hover:text-primary hover:border-primary/50 transition-colors flex items-center justify-center gap-1">
<span className="material-symbols-outlined text-[16px]">add</span>
Add Model
</button>
</div>
<div className="flex flex-col gap-2 pt-1 sm:flex-row">
<Button onClick={onClose} variant="ghost" fullWidth size="sm">Cancel</Button>
<Button onClick={handleSave} fullWidth size="sm" disabled={!name.trim() || !!nameError || saving}>
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
</Button>
</div>
</div>
);
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. */}
<Modal isOpen={isOpen} onClose={onClose} title={title || (isEdit ? "Edit Combo" : "Create Combo")}>
<div className="flex flex-col gap-3">
<div>
{forcePrefix ? (
<>
<label className="text-sm font-medium mb-1 block">Combo Name</label>
<div className="flex items-stretch">
<span className="inline-flex items-center px-2 rounded-l border border-r-0 border-black/10 dark:border-white/10 bg-black/[0.04] dark:bg-white/[0.04] text-text-muted font-mono text-sm">{forcePrefix}</span>
<input value={name} onChange={handleNameChange} placeholder="my-combo"
className="flex-1 min-w-0 rounded-r border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 px-2 py-1.5 font-mono text-sm outline-none focus:border-primary" />
</div>
{nameError && <p className="text-[11px] text-red-500 mt-0.5">{nameError}</p>}
</>
) : (
<Input label="Combo Name" value={name} onChange={handleNameChange} placeholder="my-combo" error={nameError} />
)}
<p className="text-[10px] text-text-muted mt-0.5">
{forcePrefix ? `Auto-prefixed with "${forcePrefix}". ` : ""}Only letters, numbers, -, _ and . allowed
</p>
</div>
<div>
<label className="text-sm font-medium mb-1.5 block">Models</label>
{models.length === 0 ? (
<div className="text-center py-4 border border-dashed border-black/10 dark:border-white/10 rounded-lg bg-black/[0.01] dark:bg-white/[0.01]">
<span className="material-symbols-outlined text-text-muted text-xl mb-1">layers</span>
<p className="text-xs text-text-muted">No models added yet</p>
</div>
) : (
<div className="flex max-h-[55vh] min-w-0 flex-col gap-1 overflow-y-auto sm:max-h-[350px]">
{models.map((model, index) => (
<ModelItem key={index} index={index} model={model}
isFirst={index === 0} isLast={index === models.length - 1}
onEdit={(v) => { const a = [...models]; a[index] = v; setModels(a); }}
onMoveUp={() => handleMoveUp(index)}
onMoveDown={() => handleMoveDown(index)}
onRemove={() => handleRemoveModel(index)} />
))}
</div>
)}
<button onClick={() => setShowModelSelect(true)}
className="w-full mt-2 py-2 border border-dashed border-black/10 dark:border-white/10 rounded-lg text-xs text-primary font-medium hover:text-primary hover:border-primary/50 transition-colors flex items-center justify-center gap-1">
<span className="material-symbols-outlined text-[16px]">add</span>
Add Model
</button>
</div>
<div className="flex flex-col gap-2 pt-1 sm:flex-row">
<Button onClick={onClose} variant="ghost" fullWidth size="sm">Cancel</Button>
<Button onClick={handleSave} fullWidth size="sm" disabled={!name.trim() || !!nameError || saving}>
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
</Button>
</div>
</div>
{formPane}
</Modal>
{showModelSelect && (
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
onSelect={handleAddModel} onDeselect={handleDeselectModel}
activeProviders={activeProviders} modelAliases={modelAliases}
title="Add Model to Combo" kindFilter={kindFilter}
addedModelValues={models} closeOnSelect={false} />
<div className="lg:hidden">
<ModelSelectModal
{...pickerCommon}
isOpen={showModelSelect}
onClose={() => setShowModelSelect(false)}
/>
</div>
)}
{showModelSelect && (
<ModelSelectSidePanel
{...pickerCommon}
onClose={() => setShowModelSelect(false)}
/>
)}
</>
);

View File

@@ -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 (
<div
className={cn(
"relative w-full bg-surface flex flex-col",
"border border-border-subtle",
"rounded-[14px] shadow-[var(--shadow-elev)]",
sizes[size],
className
)}
>
{(title || showTrafficLights) && (
<div className="flex items-center justify-between p-2 border-b border-border-subtle shrink-0">
<h2 className="text-lg font-semibold text-text-main ml-2">{title}</h2>
<button
onClick={onClose}
aria-label="Close"
className="p-1.5 rounded-[10px] text-text-muted hover:bg-surface-2 hover:text-text-main transition-colors"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
)}
<div className="p-6 overflow-y-auto custom-scrollbar min-h-0">{children}</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">

View File

@@ -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 */}
<div className="flex items-center gap-2 mb-3 px-2.5 py-2 bg-primary/8 border border-primary/20 rounded-lg text-xs text-text-muted">
@@ -492,7 +495,7 @@ export default function ModelSelectModal({
</div>
{/* Models grouped by provider - compact */}
<div className="max-h-[400px] overflow-y-auto space-y-3">
<div className={`overflow-y-auto space-y-3 ${sidePanel ? "max-h-[60vh]" : "max-h-[400px]"}`}>
{/* Combos section - always first */}
{filteredCombos.length > 0 && (
<div>
@@ -608,9 +611,22 @@ export default function ModelSelectModal({
</div>
)}
</div>
</Modal>
);
}
// 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 (
<div className="hidden xl:block fixed inset-0 z-[60] pointer-events-none">
<div className="absolute top-1/2 -translate-y-1/2 left-1/2 ml-[240px] w-[340px] max-h-[70vh] pointer-events-auto">
<ModelSelectModal {...props} isOpen sidePanel />
</div>
</div>
);
}
ModelSelectModal.propTypes = {
isOpen: PropTypes.bool.isRequired,

View File

@@ -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 (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
@@ -78,27 +86,25 @@ export default function RequestLogger() {
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{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 (
<tr key={i} className={`hover:bg-primary/5 transition-colors ${isPending ? 'bg-primary/5' : ''}`}>
<td className="px-3 py-1.5 border-r border-border text-text-muted">{parts[0]}</td>
<td className="px-3 py-1.5 border-r border-border font-medium">{parts[1]}</td>
<td className="px-3 py-1.5 border-r border-border text-text-muted">{log.dateTime}</td>
<td className="px-3 py-1.5 border-r border-border font-medium">{log.model}</td>
<td className="px-3 py-1.5 border-r border-border">
<span className="px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-[10px] uppercase font-bold">
{parts[2]}
{log.provider}
</span>
</td>
<td className="px-3 py-1.5 border-r border-border truncate max-w-[150px]" title={parts[3]}>{parts[3]}</td>
<td className="px-3 py-1.5 border-r border-border text-right text-primary">{parts[4]}</td>
<td className="px-3 py-1.5 border-r border-border text-right text-success">{parts[5]}</td>
<td className="px-3 py-1.5 border-r border-border truncate max-w-[150px]" title={log.account}>{log.account}</td>
<td className="px-3 py-1.5 border-r border-border text-right text-primary">{log.promptTokens ?? "-"}</td>
<td className="px-3 py-1.5 border-r border-border text-right text-success">{log.completionTokens ?? "-"}</td>
<td className={`px-3 py-1.5 font-bold ${isSuccess ? 'text-success' :
isFailed ? 'text-error' :
'text-primary animate-pulse'

View File

@@ -14,7 +14,7 @@ export { default as Sidebar } from "./Sidebar";
export { default as Header } from "./Header";
export { default as Footer } from "./Footer";
export { default as OAuthModal } from "./OAuthModal";
export { default as ModelSelectModal } from "./ModelSelectModal";
export { default as ModelSelectModal, ModelSelectSidePanel } from "./ModelSelectModal";
export { default as ManualConfigModal } from "./ManualConfigModal";
export { default as ComboFormModal } from "./ComboFormModal";
export { default as McpMarketplaceModal } from "./McpMarketplaceModal";

View File

@@ -6,6 +6,8 @@ import {
clearAccountError,
extractApiKey,
isValidApiKey,
getApiKeyRecord,
isModelAllowedForKey,
} from "../services/auth.js";
import { getSettings, getCustomModels } from "@/lib/localDb";
import { capabilitiesFromServiceKind } from "open-sse/providers/capabilities.js";
@@ -63,6 +65,7 @@ export async function handleChat(request, clientRawRequest = null) {
// Enforce API key if enabled in settings
const settings = await getSettings();
let keyRecord = null;
if (settings.requireApiKey) {
if (!apiKey) {
log.warn("AUTH", "Missing API key (requireApiKey=true)");
@@ -73,6 +76,8 @@ export async function handleChat(request, clientRawRequest = null) {
log.warn("AUTH", "Invalid API key (requireApiKey=true)");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
// Per-key model restriction (only meaningful when the key is authenticated)
keyRecord = await getApiKeyRecord(apiKey);
}
if (!modelStr) {
@@ -80,6 +85,13 @@ export async function handleChat(request, clientRawRequest = null) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
// Restrict by per-key allowedModels list. Checked before combo expansion so
// a restricted key can neither call nor route through disallowed models.
if (!isModelAllowedForKey(keyRecord, modelStr)) {
log.warn("AUTH", `API key not allowed to use model: ${modelStr}`);
return errorResponse(HTTP_STATUS.FORBIDDEN, `API key is not allowed to use model: ${modelStr}`);
}
// Bypass naming/warmup requests before combo rotation to avoid wasting rotation slots
const userAgent = request?.headers?.get("user-agent") || "";
const bypassResponse = handleBypassRequest(body, modelStr, userAgent, !!settings.ccFilterNaming);
@@ -159,6 +171,9 @@ export async function handleChat(request, clientRawRequest = null) {
* Handle single model chat request
*/
async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null) {
// Dashboard per-key tests pin the account via x-connection-id (same header
// contract as embeddings/images/video). Pinned requests must not rotate.
const preferredConnectionId = request?.headers?.get("x-connection-id") || null;
const modelInfo = await getModelInfo(modelStr);
// If provider is null, this might be a combo name - check and handle
@@ -226,7 +241,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
let lastStatus = null;
while (true) {
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { preferredConnectionId });
// All accounts unavailable
if (!credentials || credentials.allRateLimited) {
@@ -293,6 +308,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
onPxpipeEvent: appendPxpipeEvent,
providerThinking,
capsOverride,
streamErrorPatterns: chatSettings.streamErrorPatterns || null,
// Detect source format by endpoint + body
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
onCredentialsRefreshed: async (newCreds) => {
@@ -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;

View File

@@ -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("/");
});
}

View File

@@ -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 }),
);
});
});

View File

@@ -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");

View File

@@ -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);
});
});