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