Audit of every branch-owned line the -X theirs merge dropped from the 32 pre-merge commits found three more real regressions: * src/sse/handlers/chat.js: merge kept the capsOverride feature (bb8d67ba) but reverted the import block, so getCustomModels and capabilitiesFromServiceKind were undefined. The runtime error was swallowed by the feature's own fail-open try/catch — custom models silently lost their vision override. Restored both imports. * open-sse/providers/capabilities.js: TRUST_UPSTREAM_VISION (the floor that keeps vision on for unknown models on upstream-validating gateways like openrouter) was left as dead code by the merge — upstream rewrote step 4 as refine() and dropped the check. Re-applied it on top of the new refine() so catalog/limits refinement still applies. * tests/unit/chat-connection-pin.test.js: mock auth module lacked isModelAllowedForKey added byf0adfb20. * .gitignore: re-add .pi-subagents/.
117 lines
3.9 KiB
JavaScript
117 lines
3.9 KiB
JavaScript
/**
|
|
* 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),
|
|
isModelAllowedForKey: vi.fn(() => 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 }),
|
|
);
|
|
});
|
|
});
|