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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user