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:
53
CHANGELOG.md
53
CHANGELOG.md
@@ -1,3 +1,56 @@
|
||||
# Unreleased
|
||||
## Features
|
||||
- **Endpoint**: per-API-key model restrictions. Each key in Dashboard → API
|
||||
Keys gets a "Restrict models" action (tune icon) opening a modal with a
|
||||
Full-access toggle (default) and a multi-select model picker (reuses
|
||||
ModelSelectModal — provider-grouped chips, search, combos). Restricted keys
|
||||
get HTTP 403 on chat calls outside the list (checked before combo expansion,
|
||||
so a combo routing into a disallowed model is also blocked) and /v1/models
|
||||
returns only the allowed models for that key; keys without a restriction
|
||||
keep full access and see everything.
|
||||
|
||||
## Fixes
|
||||
- **Providers**: honor `x-connection-id` in `/v1/chat/completions` — the
|
||||
dashboard's per-key Test (and any client pinning) previously routed through
|
||||
whichever account the strategy picked, so "test key X" could pass or fail on
|
||||
account Y. Pinned requests now select exactly that account and do not rotate
|
||||
on failure, matching the embeddings/images/video handlers.
|
||||
- **Providers**: saving a per-provider fallback strategy no longer wipes a
|
||||
stored `enabled: false` override — toggling a free provider off and then
|
||||
changing Round-Robin silently re-enabled it. Strategy saves now merge into
|
||||
the existing override (detail page and media-provider ConnectionsCard).
|
||||
- **Providers**: Test All group filters match the grid sections — freeTier
|
||||
providers' API-key connections were batch-tested under "apikey" while their
|
||||
cards render in Free Tier; Custom Providers had no Test All button.
|
||||
- **Providers**: Test Connection One-by-One skips disabled connections instead
|
||||
of testing-and-failing them (same population as routing and bulk test).
|
||||
- **Dashboard**: provider-card enable/disable failures surface a notification
|
||||
and re-sync from the server instead of silently diverging from the DB.
|
||||
|
||||
## UX
|
||||
- **Providers**: Test Selected Keys modal lists the selected accounts (with a
|
||||
count) before the model picker; each result row fills in live as its test
|
||||
settles, and after a run two actions appear — Disable Failed (kills the
|
||||
failing accounts, marked "disabled" in the list) and Retry Failed (re-runs
|
||||
only the accounts that failed).
|
||||
- **Providers**: connections toolbar groups bulk actions (proxy / delete /
|
||||
disable / test) apart from provider-wide settings (Connect Timeout,
|
||||
Round Robin) with a divider; "Test Connection One-by-One" renamed to
|
||||
"Test All Connections (1-by-1)" so it reads as the complement of
|
||||
"Test Selected (N)".
|
||||
- **Dashboard**: grid-card toggles are always visible instead of
|
||||
hover-revealed — hover-only affordances are unusable on touch devices.
|
||||
Connectable free providers (Kiro, Gemini CLI) with zero connections keep an
|
||||
enable/disable toggle (free bandwidth tiers have no per-connection switch),
|
||||
with a "Free · Connect" badge alongside; noAuth providers show "Ready".
|
||||
- **Providers**: connection rows get a ✕ next to stale error banners; clearing
|
||||
persists `lastError`/`lastErrorAt` to the server so the banner doesn't
|
||||
re-appear on reload.
|
||||
|
||||
## Tests
|
||||
- `tests/unit/chat-connection-pin.test.js`: pin header forwarded as
|
||||
`preferredConnectionId`; unpinned requests stay unpinned.
|
||||
|
||||
# v0.5.55 (2026-08-14)
|
||||
|
||||
## Features
|
||||
|
||||
10
CLAUDE.md
10
CLAUDE.md
@@ -89,3 +89,13 @@ Pre-translate hooks that compress `tool_result` content in-place to cut tokens.
|
||||
- Security-sensitive env: `JWT_SECRET` (session cookie), `INITIAL_PASSWORD` (default `123456` — must override), `API_KEY_SECRET`, `MACHINE_ID_SALT`. Full env contract in `.env.example` and ARCHITECTURE.md's env matrix.
|
||||
- Binary/protobuf upstreams (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — they're handled inside their own executor, not the translator.
|
||||
- Versioning: root and `cli/` are versioned independently; changes are logged in `CHANGELOG.md`. Commit style is Conventional Commits (`fix(translator): …`, `feat(...)`).
|
||||
|
||||
<!-- 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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": "*" },
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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)"],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("/");
|
||||
});
|
||||
}
|
||||
|
||||
115
tests/unit/chat-connection-pin.test.js
Normal file
115
tests/unit/chat-connection-pin.test.js
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
|
||||
41
tests/unit/request-scoped-fallback.test.js
Normal file
41
tests/unit/request-scoped-fallback.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user