Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/migrations/20260813_02_user_model_committees.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Per-user committee definitions managed from Account > Model Preferences.
alter table public.user_profiles
add column if not exists model_committees jsonb not null default '[]'::jsonb;
10 changes: 10 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.7.1",
"helmet": "^8.1.0",
"jsonrepair": "^3.15.0",
"jszip": "^3.10.1",
"libreoffice-convert": "^1.6.0",
"mammoth": "^1.9.0",
Expand Down
1 change: 1 addition & 0 deletions backend/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ create table if not exists public.user_profiles (
quote_model text,
mfa_on_login boolean not null default false,
legal_research_us boolean not null default true,
model_committees jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Expand Down
132 changes: 132 additions & 0 deletions backend/src/__tests__/integration/models.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Request, Response } from "express";

const { getUserApiKeys } = vi.hoisted(() => ({
getUserApiKeys: vi.fn(),
}));

vi.mock("../../middleware/auth", () => ({
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
next: () => void,
) => {
res.locals.userId = "user-1";
next();
},
}));

vi.mock("../../lib/userApiKeys", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../lib/userApiKeys")>();
return { ...actual, getUserApiKeys };
});

import { openRouterModelsHandler } from "../../routes/models";

const originalFetch = global.fetch;

beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
global.fetch = originalFetch;
});

function responseHarness() {
let statusCode = 200;
let body: unknown;
const res = {
locals: { userId: "user-1" },
status: vi.fn((code: number) => {
statusCode = code;
return res;
}),
json: vi.fn((value: unknown) => {
body = value;
return res;
}),
} as unknown as Response;
return {
res,
result: () => ({ statusCode, body }),
};
}

async function invokeHandler() {
const harness = responseHarness();
await openRouterModelsHandler({} as Request, harness.res);
return harness.result();
}

describe("GET /models/openrouter", () => {
it("loads and normalizes models with the authenticated user's key", async () => {
getUserApiKeys.mockResolvedValue({ openrouter: "sk-or-user" });
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
data: [
{
id: "anthropic/claude-sonnet-4",
name: "Claude Sonnet 4",
},
],
}),
{ status: 200 },
),
);
global.fetch = fetchMock;

const response = await invokeHandler();

expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
models: [
{
id: "openrouter/anthropic/claude-sonnet-4",
label: "Claude Sonnet 4",
group: "OpenRouter",
},
],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://openrouter.ai/api/v1/models",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer sk-or-user",
}),
}),
);
});

it("does not contact OpenRouter when no key is configured", async () => {
getUserApiKeys.mockResolvedValue({ openrouter: null });
const fetchMock = vi.fn();
global.fetch = fetchMock;

const response = await invokeHandler();

expect(response.statusCode).toBe(400);
expect(response.body).toEqual({
detail: "OpenRouter API key is not configured.",
});
expect(fetchMock).not.toHaveBeenCalled();
});

it("maps upstream catalog failures to a safe gateway error", async () => {
getUserApiKeys.mockResolvedValue({ openrouter: "sk-or-user" });
global.fetch = vi
.fn()
.mockResolvedValue(
new Response("provider details", { status: 401 }),
);

const response = await invokeHandler();

expect(response.statusCode).toBe(502);
expect(response.body).toEqual({
detail: "Unable to load the OpenRouter model catalog.",
});
});
});
141 changes: 138 additions & 3 deletions backend/src/__tests__/integration/user.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,14 @@ let supabaseState: {
adminGetUserById: QueryResult;
adminDeleteUser: { error: unknown };
};
let profileSelectResults: QueryResult[] = [];
let profileSelects: string[] = [];
let lastDbUpdate: unknown = null;

function resetSupabaseState() {
profileSelectResults = [];
profileSelects = [];
lastDbUpdate = null;
supabaseState = {
tables: {},
adminGetUserById: {
Expand All @@ -68,6 +74,13 @@ function resultForTable(table: string): QueryResult {
return supabaseState.tables[table] ?? { data: null, error: null };
}

function resultForQuery(table: string): QueryResult {
if (table === "user_profiles" && profileSelectResults.length > 0) {
return profileSelectResults.shift()!;
}
return resultForTable(table);
}

function makeQuery(table: string) {
const q: Record<string, unknown> = {};
const chain = [
Expand All @@ -76,12 +89,20 @@ function makeQuery(table: string) {
"filter", "order", "limit", "range", "contains",
];
for (const m of chain) q[m] = vi.fn(() => q);
q.single = vi.fn(() => Promise.resolve(resultForTable(table)));
q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table)));
q.select = vi.fn((columns: string) => {
if (table === "user_profiles") profileSelects.push(columns);
return q;
});
q.update = vi.fn((value: unknown) => {
lastDbUpdate = value;
return q;
});
q.single = vi.fn(() => Promise.resolve(resultForQuery(table)));
q.maybeSingle = vi.fn(() => Promise.resolve(resultForQuery(table)));
q.then = (
resolve: (v: unknown) => unknown,
reject?: (e: unknown) => unknown,
) => Promise.resolve(resultForTable(table)).then(resolve, reject);
) => Promise.resolve(resultForQuery(table)).then(resolve, reject);
return q;
}

Expand Down Expand Up @@ -231,12 +252,37 @@ describe("user.routes", () => {
tier: "Pro",
legalResearchUs: true,
mfaOnLogin: false,
modelCommittees: [],
apiKeyStatus: STATUS,
});
// Presence-only key status — never plaintext.
expect(JSON.stringify(res.body)).not.toContain("sk-");
});

it("retries with model_committees dropped when the column is missing", async () => {
// Older databases without the model_committees column should
// still load: selectProfile retries without the column and
// defaults the field to [].
profileSelectResults.push(
{
data: null,
error: {
code: "42703",
message: 'column "model_committees" does not exist',
},
},
{ data: profileRow(), error: null },
);

const res = await request(app).get("/user/profile").set(...AUTH);

expect(res.status).toBe(200);
expect(res.body.modelCommittees).toEqual([]);
expect(profileSelects).toHaveLength(2);
expect(profileSelects[0]).toContain("model_committees");
expect(profileSelects[1]).not.toContain("model_committees");
});

it("is NOT guarded by requireMfaIfEnrolled (bootstrap route)", async () => {
// Even if the MFA factor were unsatisfied, profile must remain
// reachable so the client can render the verification gate.
Expand Down Expand Up @@ -276,6 +322,95 @@ describe("user.routes", () => {
});
});

// ── PATCH /user/profile (committee configuration) ──────────────────────
describe("PATCH /user/profile", () => {
it("persists and returns modelCommittees", async () => {
const committees = [
{
id: "user-committee/review",
label: "Review Board",
members: ["gpt-5.4", "claude-haiku-4-5"],
chair: "gemini-3-flash-preview",
},
];
supabaseState.tables.user_profiles = {
data: profileRow({ model_committees: committees }),
error: null,
};

const res = await request(app)
.patch("/user/profile")
.set(...AUTH)
.send({ modelCommittees: committees });

expect(res.status).toBe(200);
expect(lastDbUpdate).toMatchObject({
model_committees: [
expect.objectContaining({
id: "user-committee/review",
strategy: "synthesize",
}),
],
});
expect(res.body.modelCommittees).toEqual([
expect.objectContaining({ id: "user-committee/review" }),
]);
});

it("rejects a committee with unknown member models", async () => {
supabaseState.tables.user_profiles = {
data: profileRow(),
error: null,
};

const res = await request(app)
.patch("/user/profile")
.set(...AUTH)
.send({
modelCommittees: [
{
id: "user-committee/bad",
label: "Bad Board",
members: ["gpt-5.4", "not-a-real-model"],
chair: "gemini-3-flash-preview",
},
],
});

expect(res.status).toBe(400);
expect(res.body.detail).toBe("Unknown committee model: not-a-real-model");
});
});

// ── GET /user/models (configured model catalog) ────────────────────────
describe("GET /user/models", () => {
it("includes the user's personal committees in the catalog", async () => {
const committees = [
{
id: "user-committee/review",
label: "Review Board",
members: ["gpt-5.4", "claude-haiku-4-5"],
chair: "gemini-3-flash-preview",
strategy: "synthesize",
},
];
supabaseState.tables.user_profiles = {
data: { model_committees: committees },
error: null,
};

const res = await request(app).get("/user/models").set(...AUTH);

expect(res.status).toBe(200);
expect(res.body.configured).toContainEqual({
id: "user-committee/review",
label: "Review Board",
provider: "committee",
location: "committee",
});
});
});

// ── GET /user/api-keys (presence without plaintext) ───────────────────
describe("GET /user/api-keys", () => {
it("returns the boolean key-status map", async () => {
Expand Down
Loading