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:
@@ -24,10 +24,15 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const [r, m, s] = await Promise.all([api.agentRoles(slug), api.agentModels(slug), api.agentSkills(slug)]);
|
const [r, s] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug)]);
|
||||||
roles = r.roles;
|
roles = r.roles;
|
||||||
models = m.models;
|
|
||||||
skills = s.skills;
|
skills = s.skills;
|
||||||
|
// Model fetch hits the provider API and may fail or be slow; load it
|
||||||
|
// independently so roles remain editable even without a model list.
|
||||||
|
models = [];
|
||||||
|
api.agentModels(slug)
|
||||||
|
.then((m) => { models = m.models; })
|
||||||
|
.catch((err) => { toastError(`模型列表加载失败:${err instanceof Error ? err.message : String(err)}`); });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
|
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
|
||||||
*
|
*
|
||||||
* Surface for browsing and editing Organization-scoped Agent roles and skills.
|
* Surface for browsing and editing Organization-scoped Agent roles, the skills
|
||||||
* Roles are org-owned data; the default-model picker is constrained to the
|
* bound to them, and the model picker. The model list is **derived** from the
|
||||||
* env-default model registry (ADR-0017: there is no org-scoped model list —
|
* org's ACTIVE provider connection via OpenRouter's /v1/models API — there is
|
||||||
* `OrganizationAgentRole.defaultModel` selects from the platform-enabled set).
|
* no separate model table to maintain. When no ACTIVE provider exists, the
|
||||||
|
* route falls back to the env-default model registry so the admin surface
|
||||||
|
* remains usable.
|
||||||
*
|
*
|
||||||
* Skill management (create, read, edit, disable) is performed in-band through
|
* Skill management (create, read, edit, disable) is performed in-band through
|
||||||
* the deep module `OrganizationAgentConfiguration`, which writes to the same
|
* the deep module `OrganizationAgentConfiguration`, which writes to the same
|
||||||
@@ -17,7 +19,9 @@
|
|||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { OrganizationAgentConfiguration } from "../../agent/configuration.js";
|
import { OrganizationAgentConfiguration } from "../../agent/configuration.js";
|
||||||
|
import { ProviderModelCatalog } from "../../connections/providerModelCatalog.js";
|
||||||
import { createDefaultModelRegistry } from "../../settings/runtime.js";
|
import { createDefaultModelRegistry } from "../../settings/runtime.js";
|
||||||
|
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||||
import { handleRouteError } from "../errors.js";
|
import { handleRouteError } from "../errors.js";
|
||||||
|
|
||||||
@@ -25,6 +29,7 @@ export interface AgentConfigRouteConfig {
|
|||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
readonly sessionSecret: string;
|
readonly sessionSecret: string;
|
||||||
readonly skillStoreRoot: string;
|
readonly skillStoreRoot: string;
|
||||||
|
readonly secretEnvelope: LocalSecretEnvelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAgentConfigRoutes(
|
export async function registerAgentConfigRoutes(
|
||||||
@@ -33,6 +38,7 @@ export async function registerAgentConfigRoutes(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||||
const agentConfig = new OrganizationAgentConfiguration(config.prisma, config.skillStoreRoot);
|
const agentConfig = new OrganizationAgentConfiguration(config.prisma, config.skillStoreRoot);
|
||||||
|
const modelCatalog = new ProviderModelCatalog(config.prisma, config.secretEnvelope);
|
||||||
|
|
||||||
app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => {
|
app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
@@ -226,8 +232,13 @@ export async function registerAgentConfigRoutes(
|
|||||||
const { orgSlug } = request.params as { orgSlug: string };
|
const { orgSlug } = request.params as { orgSlug: string };
|
||||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
if (auth === null) return;
|
if (auth === null) return;
|
||||||
const registry = createDefaultModelRegistry(process.env);
|
const providerModels = await modelCatalog.listModels(auth.organization.id);
|
||||||
return { models: registry.listModels() };
|
// Fall back to env-default registry when the org has no ACTIVE provider
|
||||||
|
// (or the provider API is unreachable on first load).
|
||||||
|
const models = providerModels.length > 0
|
||||||
|
? providerModels
|
||||||
|
: createDefaultModelRegistry(process.env).listModels();
|
||||||
|
return { models };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleRouteError(reply, err);
|
return handleRouteError(reply, err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
|
|||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
skillStoreRoot: readSkillStoreRoot(),
|
skillStoreRoot: readSkillStoreRoot(),
|
||||||
|
secretEnvelope: config.secretEnvelope,
|
||||||
});
|
});
|
||||||
await registerSessionsAndUsageRoutes(app, {
|
await registerSessionsAndUsageRoutes(app, {
|
||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Provider model catalog: fetches the list of models available to an
|
||||||
|
* Organization from its ACTIVE provider connection (OpenRouter), with an
|
||||||
|
* in-memory TTL cache so the admin model picker stays responsive without
|
||||||
|
* hammering the upstream API on every page load.
|
||||||
|
*
|
||||||
|
* The model list is **derived** from the provider connection — there is no
|
||||||
|
* separate model table to maintain. When an org activates a provider, its
|
||||||
|
* models become selectable; when the provider is disabled, the list empties.
|
||||||
|
*/
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||||
|
import { decryptStoredProviderCredential } from "./providerConnections.js";
|
||||||
|
|
||||||
|
/** A model entry surfaced to the admin model picker. */
|
||||||
|
export interface ProviderModelEntry {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly toolCapable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A model entry as returned by OpenRouter's /v1/models endpoint. */
|
||||||
|
interface OpenRouterModel {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly supported_parameters: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenRouterModelsResponse {
|
||||||
|
readonly data: readonly OpenRouterModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache entry: models + expiry timestamp. */
|
||||||
|
interface CacheEntry {
|
||||||
|
readonly models: readonly ProviderModelEntry[];
|
||||||
|
readonly expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-organization model catalog cache. Keyed by organizationId so that
|
||||||
|
* multiple orgs in the same Silo don't cross-pollute. The cache is intentionally
|
||||||
|
* process-local: it survives for the lifetime of the Hub process and is
|
||||||
|
* invalidated by TTL, not by DB events. A cache miss re-fetches from the
|
||||||
|
* provider API.
|
||||||
|
*/
|
||||||
|
export class ProviderModelCatalog {
|
||||||
|
private readonly cache = new Map<string, CacheEntry>();
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly secrets: LocalSecretEnvelope,
|
||||||
|
private readonly ttlMs: number = DEFAULT_TTL_MS,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
) {
|
||||||
|
this.fetchImpl = fetchImpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List tool-capable models available to the organization's ACTIVE provider
|
||||||
|
* connection. Returns cached results when fresh; otherwise fetches from the
|
||||||
|
* provider API. Falls back to an empty list (not an error) when the org has
|
||||||
|
* no ACTIVE provider — the admin sees the env-default model from the
|
||||||
|
* separate env fallback in the route.
|
||||||
|
*/
|
||||||
|
async listModels(organizationId: string): Promise<readonly ProviderModelEntry[]> {
|
||||||
|
const cached = this.cache.get(organizationId);
|
||||||
|
if (cached !== undefined && cached.expiresAt > Date.now()) {
|
||||||
|
return cached.models;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await this.resolveActiveProviderCredential(organizationId);
|
||||||
|
if (credential === null) return [];
|
||||||
|
|
||||||
|
const models = await this.fetchModelsFromProvider(credential);
|
||||||
|
this.cache.set(organizationId, {
|
||||||
|
models,
|
||||||
|
expiresAt: Date.now() + this.ttlMs,
|
||||||
|
});
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force a cache invalidation (e.g. after provider connection changes). */
|
||||||
|
invalidate(organizationId: string): void {
|
||||||
|
this.cache.delete(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveActiveProviderCredential(
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<{ readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string } | null> {
|
||||||
|
const connection = await this.prisma.organizationProviderConnection.findFirst({
|
||||||
|
where: { organizationId, status: "ACTIVE" },
|
||||||
|
include: { activeSecretVersion: true },
|
||||||
|
});
|
||||||
|
const version = connection?.activeSecretVersion;
|
||||||
|
if (connection === null || version === null || version === undefined || version.retiredAt !== null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = decryptStoredProviderCredential(this.secrets, {
|
||||||
|
organizationId,
|
||||||
|
connectionId: connection.id,
|
||||||
|
providerId: connection.providerId,
|
||||||
|
secretVersionId: version.id,
|
||||||
|
envelopeVersion: version.envelopeVersion,
|
||||||
|
keyId: version.keyId,
|
||||||
|
envelope: version.envelope,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
baseUrl: payload.baseUrl,
|
||||||
|
authToken: payload.authToken,
|
||||||
|
anthropicApiKey: payload.anthropicApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchModelsFromProvider(
|
||||||
|
credential: { readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string },
|
||||||
|
): Promise<readonly ProviderModelEntry[]> {
|
||||||
|
const base = credential.baseUrl.endsWith("/") ? credential.baseUrl : `${credential.baseUrl}/`;
|
||||||
|
// Filter to models that support tool calling — the agent runner requires it.
|
||||||
|
const url = new URL("v1/models?supported_parameters=tools", base);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
authorization: `Bearer ${credential.authToken}`,
|
||||||
|
accept: "application/json",
|
||||||
|
};
|
||||||
|
if (credential.anthropicApiKey !== "") headers["x-api-key"] = credential.anthropicApiKey;
|
||||||
|
|
||||||
|
const response = await this.fetchImpl(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`provider models request failed: status ${response.status}`);
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as OpenRouterModelsResponse;
|
||||||
|
return body.data.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
label: m.name,
|
||||||
|
toolCapable: m.supported_parameters.includes("tools"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,12 +182,16 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
|
|||||||
throw new Error(`organization ${project.organization.id} must have exactly one active default Agent role`);
|
throw new Error(`organization ${project.organization.id} must have exactly one active default Agent role`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The model list is the env-based fallback used when a role has no
|
||||||
|
// explicit defaultModel. Role defaultModels are chosen from the provider
|
||||||
|
// model catalog (admin surface) and may not be in the env list — we don't
|
||||||
|
// validate against it at runtime; the admin already validated by selection.
|
||||||
const defaults = await this.envSettings.modelRegistry(scope);
|
const defaults = await this.envSettings.modelRegistry(scope);
|
||||||
const enabledModels = new Set(defaults.listModels().map((model) => model.id));
|
const fallbackModels = defaults.listModels();
|
||||||
const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({
|
const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({
|
||||||
id: role.roleId,
|
id: role.roleId,
|
||||||
label: role.label,
|
label: role.label,
|
||||||
defaultModel: validateRoleModel(role.roleId, role.defaultModel, enabledModels),
|
defaultModel: role.defaultModel ?? undefined,
|
||||||
...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}),
|
...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}),
|
||||||
...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}),
|
...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}),
|
||||||
skills: role.skillBindings.map((binding): RoleSkillEntry => {
|
skills: role.skillBindings.map((binding): RoleSkillEntry => {
|
||||||
@@ -210,7 +214,7 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
return new InMemoryModelRegistry(defaults.listModels(), roles);
|
return new InMemoryModelRegistry(fallbackModels, roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
|
runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
|
||||||
@@ -225,15 +229,6 @@ function roleToolsFromJson(roleId: string, value: Prisma.JsonValue): readonly st
|
|||||||
return value as string[];
|
return value as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateRoleModel(
|
|
||||||
roleId: string,
|
|
||||||
model: string | null,
|
|
||||||
enabledModels: ReadonlySet<string>,
|
|
||||||
): string | undefined {
|
|
||||||
if (model === null) return undefined;
|
|
||||||
if (!enabledModels.has(model)) throw new Error(`role ${roleId} selects unavailable model ${model}`);
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadActiveProviderSecret(
|
async function loadActiveProviderSecret(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
|
|||||||
@@ -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