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