forked from EduCraft/curriculum-project-hub
feat(hub): derive admin model picker from org provider connection via OpenRouter API
The admin role model picker was hardcoded to the env-default model registry (createDefaultModelRegistry), which only ever returned a single Sonnet model. Roles could not select any other model regardless of what the org's provider connection supported. Replace the env-only model list with a ProviderModelCatalog that: - Resolves the org's ACTIVE provider connection credential (BYOK or platform-managed, encrypted via ADR-0024 envelope) - Calls OpenRouter GET /v1/models?supported_parameters=tools to list tool-capable models available to that org - Caches results in-memory with a 5-minute TTL per organization - Falls back to the env-default registry when no ACTIVE provider exists The runtime modelRegistry no longer validates role.defaultModel against the env model list — the admin already validated by selection from the provider catalog. The env list remains as the fallback for roles with null defaultModel. The admin roles page loads models independently (non-blocking) so roles remain editable even if the provider API is slow or unreachable.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { ProviderModelCatalog, type ProviderModelEntry } from "../../src/connections/providerModelCatalog.js";
|
||||
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
|
||||
const TEST_KEY = Buffer.alloc(32, "k");
|
||||
const secrets = new LocalSecretEnvelope({
|
||||
activeKeyId: "test-active",
|
||||
keys: new Map([["test-active", TEST_KEY]]),
|
||||
});
|
||||
|
||||
function makeEncryptedPayload(baseUrl: string, authToken: string, anthropicApiKey = "") {
|
||||
const payload = { schemaVersion: 1, baseUrl, authToken, anthropicApiKey };
|
||||
return secrets.encryptJson({ purpose: "provider-connection", organizationId: "org-1", connectionId: "conn-1", secretVersionId: "sv-1" }, payload);
|
||||
}
|
||||
|
||||
function mockPrismaWithConnection(overrides: Partial<{
|
||||
status: string;
|
||||
retiredAt: Date | null;
|
||||
providerId: string;
|
||||
}> = {}) {
|
||||
const envelope = makeEncryptedPayload("https://openrouter.ai/api", "sk-test-key", "sk-ant-test");
|
||||
return {
|
||||
organizationProviderConnection: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "conn-1",
|
||||
organizationId: "org-1",
|
||||
providerId: overrides.providerId ?? "openrouter",
|
||||
status: overrides.status ?? "ACTIVE",
|
||||
activeSecretVersion: {
|
||||
id: "sv-1",
|
||||
connectionId: "conn-1",
|
||||
envelopeVersion: 1,
|
||||
keyId: "test-active",
|
||||
envelope,
|
||||
retiredAt: overrides.retiredAt ?? null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetchResponse(models: Array<{ id: string; name: string; supported_parameters: string[] }>) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ data: models }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProviderModelCatalog", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("fetches tool-capable models from the provider API", async () => {
|
||||
const prisma = mockPrismaWithConnection();
|
||||
const fetchImpl = mockFetchResponse([
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools", "temperature"] },
|
||||
{ id: "openai/gpt-4o", name: "GPT-4o", supported_parameters: ["tools", "temperature"] },
|
||||
{ id: "meta/llama-3", name: "Llama 3", supported_parameters: ["temperature"] },
|
||||
]);
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
const models = await catalog.listModels("org-1");
|
||||
|
||||
expect(models).toHaveLength(3);
|
||||
expect(models.map((m: ProviderModelEntry) => m.id)).toEqual([
|
||||
"anthropic/claude-sonnet-5",
|
||||
"openai/gpt-4o",
|
||||
"meta/llama-3",
|
||||
]);
|
||||
expect(models[0]).toMatchObject({ label: "Claude Sonnet 5", toolCapable: true });
|
||||
expect(models[2]).toMatchObject({ label: "Llama 3", toolCapable: false });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
const calledUrl = String(fetchImpl.mock.calls[0][0]);
|
||||
expect(calledUrl).toContain("supported_parameters=tools");
|
||||
});
|
||||
|
||||
it("returns cached results on subsequent calls within TTL", async () => {
|
||||
const prisma = mockPrismaWithConnection();
|
||||
const fetchImpl = mockFetchResponse([
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
|
||||
]);
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
await catalog.listModels("org-1");
|
||||
await catalog.listModels("org-1");
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-fetches after TTL expires", async () => {
|
||||
const prisma = mockPrismaWithConnection();
|
||||
const fetchImpl = mockFetchResponse([
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
|
||||
]);
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
await catalog.listModels("org-1");
|
||||
vi.advanceTimersByTime(61_000);
|
||||
await catalog.listModels("org-1");
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns empty list when no ACTIVE provider connection exists", async () => {
|
||||
const prisma = {
|
||||
organizationProviderConnection: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
const fetchImpl = mockFetchResponse([]);
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
const models = await catalog.listModels("org-1");
|
||||
|
||||
expect(models).toEqual([]);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalidate forces a re-fetch on next call", async () => {
|
||||
const prisma = mockPrismaWithConnection();
|
||||
const fetchImpl = mockFetchResponse([
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
|
||||
]);
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
await catalog.listModels("org-1");
|
||||
catalog.invalidate("org-1");
|
||||
await catalog.listModels("org-1");
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("sends auth token and anthropic API key in headers", async () => {
|
||||
const prisma = mockPrismaWithConnection();
|
||||
const fetchImpl = mockFetchResponse([
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
|
||||
]);
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
await catalog.listModels("org-1");
|
||||
|
||||
const init = fetchImpl.mock.calls[0][1] as RequestInit;
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers.authorization).toBe("Bearer sk-test-key");
|
||||
expect(headers["x-api-key"]).toBe("sk-ant-test");
|
||||
});
|
||||
|
||||
it("throws on non-200 response", async () => {
|
||||
const prisma = mockPrismaWithConnection();
|
||||
const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 401 });
|
||||
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||
|
||||
await expect(catalog.listModels("org-1")).rejects.toThrow("provider models request failed: status 401");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user