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

- Endpoint: per-API-key model allowlist (schema v3) enforced on chat (403)
  and /v1/models; Full-access toggle + multi-select picker in Keys UI.
- Providers: honor x-connection-id in /v1/chat/completions — pinned requests
  no longer rotate to another account on failure.
- Providers: strategy saves merge into stored enabled:false override;
  Test All groups match grid sections; 1-by-1 skips disabled connections.
- Dashboard: provider-card toggle syncs from server on failure; grid toggles
  always visible; connection rows get clear-✕ for stale error banners.
- Combo editor: on desktop (xl+) the Add-Model picker opens as a floating
  side panel beside the untouched combo popup instead of stacking on top;
  mobile keeps the full-screen overlay.
- Long API-key overflow fixed in key rows + provider model sections.
This commit is contained in:
2026-08-27 09:26:17 +07:00
parent 1d56e2dbc5
commit f0adfb205a
42 changed files with 1028 additions and 339 deletions

View File

@@ -0,0 +1,115 @@
/**
* Unit tests for x-connection-id pinning in src/sse/handlers/chat.js
*
* Covers:
* - the dashboard per-key test header is forwarded to getProviderCredentials
* as preferredConnectionId (same contract as embeddings/images/video)
* - a pinned request does NOT rotate to another account on failure
* - an unpinned request keeps rotating (regression guard)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const authMocks = vi.hoisted(() => ({
getProviderCredentials: vi.fn(),
markAccountUnavailable: vi.fn(async () => ({ shouldFallback: true })),
clearAccountError: vi.fn(async () => {}),
extractApiKey: vi.fn(() => null),
isValidApiKey: vi.fn(async () => true),
}));
const tokenMocks = vi.hoisted(() => ({
checkAndRefreshToken: vi.fn(async (_p, creds) => creds),
updateProviderCredentials: vi.fn(async () => {}),
}));
vi.mock("@/sse/services/auth.js", () => authMocks);
vi.mock("@/sse/services/tokenRefresh.js", () => tokenMocks);
vi.mock("@/lib/localDb", () => ({
getSettings: vi.fn(async () => ({ requireApiKey: false })),
getComboByName: vi.fn(async () => null),
getModelAliases: vi.fn(async () => ({})),
getCustomModels: vi.fn(async () => []),
getProviderNodes: vi.fn(async () => []),
getProviderConnections: vi.fn(async () => []),
updateProviderCredentials: vi.fn(async () => {}),
}));
vi.mock("@/sse/utils/logger.js", () => ({
info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), maskKey: (k) => k,
}));
// chatCore would perform the upstream call; stub success so the loop exits.
vi.mock("open-sse/handlers/chatCore.js", () => ({
handleChatCore: vi.fn(async ({ credentials }) => ({
success: true,
response: new Response(JSON.stringify({ servedBy: credentials.connectionId }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
})),
}));
vi.mock("open-sse/services/combo.js", () => ({
getComboModels: vi.fn(async () => null),
resetComboRotation: vi.fn(),
detectRequiredCapabilities: vi.fn(() => new Set()),
augmentModelsWithCapacityAdapter: vi.fn((m) => m),
withCapacityAdapterStripping: vi.fn((fn) => fn),
getActiveAdapterStrategy: vi.fn(() => "fallback"),
}));
import { handleChat } from "@/sse/handlers/chat.js";
const originalFetch = global.fetch;
const makeRequest = (body, headers = {}) =>
new Request("http://localhost/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(body),
});
const account = (id) => ({
connectionId: id,
connectionName: `acc-${id}`,
accessToken: "tok",
refreshToken: "ref",
authType: "oauth",
});
beforeEach(() => {
global.fetch = vi.fn();
authMocks.getProviderCredentials.mockReset();
authMocks.getProviderCredentials.mockImplementation(async (_p, exclude) =>
exclude.size === 0 ? account("conn-A") : null,
);
authMocks.markAccountUnavailable.mockClear();
authMocks.clearAccountError.mockClear();
tokenMocks.checkAndRefreshToken.mockClear();
});
afterEach(() => {
global.fetch = originalFetch;
});
describe("chat x-connection-id pinning", () => {
it("forwards x-connection-id as preferredConnectionId", async () => {
const res = await handleChat(
makeRequest({ model: "prov/m1", messages: [{ role: "user", content: "hi" }] }, { "x-connection-id": "conn-B" }),
);
expect(res.status).toBe(200);
expect(authMocks.getProviderCredentials).toHaveBeenCalledWith(
"prov",
expect.anything(),
"m1",
expect.objectContaining({ preferredConnectionId: "conn-B" }),
);
});
it("passes null when no pin header is present", async () => {
await handleChat(makeRequest({ model: "prov/m1", messages: [{ role: "user", content: "hi" }] }));
expect(authMocks.getProviderCredentials).toHaveBeenCalledWith(
"prov",
expect.anything(),
"m1",
expect.objectContaining({ preferredConnectionId: null }),
);
});
});

View File

@@ -14,7 +14,7 @@ vi.mock("@/shared/constants/providers.js", () => ({
FREE_PROVIDERS: {},
resolveProviderId: (provider) => provider,
}));
vi.mock("@/sse/utils/logger.js", () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn() }));
vi.mock("@/sse/utils/logger.js", () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }));
const { markAccountUnavailable } = await import("../../src/sse/services/auth.js");

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { checkFallbackError, getQuotaCooldown } from "../../open-sse/services/accountFallback.js";
describe("checkFallbackError — request-scoped errors (P2)", () => {
it("classifies context-overflow messages as request-scoped: no fallback, no cooldown", () => {
const msgs = [
"This model's maximum context length is 16385 tokens",
"prompt is too long: 250000 tokens > 200000 maximum",
"input is too long for requested model",
"Invalid parameter: max_tokens exceed model limit",
"context_length_exceeded",
"Please reduce the length of the messages",
"Your request is too large for the context window",
];
for (const message of msgs) {
const r = checkFallbackError(400, message);
expect(r.shouldFallback, message).toBe(false);
expect(r.requestScoped, message).toBe(true);
expect(r.cooldownMs).toBe(0);
}
});
it("does NOT classify generic 4xx as request-scoped (legacy transient path preserved)", () => {
const r = checkFallbackError(400, "Invalid value for 'temperature'");
expect(r.requestScoped).toBeUndefined();
expect(r.shouldFallback).toBe(true);
expect(r.cooldownMs).toBeGreaterThan(0);
});
it("rate-limit text still backs off with fallback", () => {
const r = checkFallbackError(429, "rate limit exceeded", 0);
expect(r.shouldFallback).toBe(true);
expect(r.cooldownMs).toBe(getQuotaCooldown(1));
});
it("no credentials still falls back with long cooldown", () => {
const r = checkFallbackError(403, "no credentials found for account");
expect(r.shouldFallback).toBe(true);
expect(r.cooldownMs).toBe(2 * 60 * 1000);
});
});