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:
2026-07-16 01:24:00 +08:00
parent 3ee6da7ceb
commit 35251986af
6 changed files with 332 additions and 20 deletions
@@ -24,10 +24,15 @@
loading = true;
error = null;
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;
models = m.models;
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) {
error = err instanceof Error ? err.message : String(err);
} finally {
+17 -6
View File
@@ -1,10 +1,12 @@
/**
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
*
* Surface for browsing and editing Organization-scoped Agent roles and skills.
* Roles are org-owned data; the default-model picker is constrained to the
* env-default model registry (ADR-0017: there is no org-scoped model list —
* `OrganizationAgentRole.defaultModel` selects from the platform-enabled set).
* Surface for browsing and editing Organization-scoped Agent roles, the skills
* bound to them, and the model picker. The model list is **derived** from the
* org's ACTIVE provider connection via OpenRouter's /v1/models API — there is
* 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
* the deep module `OrganizationAgentConfiguration`, which writes to the same
@@ -17,7 +19,9 @@
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { OrganizationAgentConfiguration } from "../../agent/configuration.js";
import { ProviderModelCatalog } from "../../connections/providerModelCatalog.js";
import { createDefaultModelRegistry } from "../../settings/runtime.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
@@ -25,6 +29,7 @@ export interface AgentConfigRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly skillStoreRoot: string;
readonly secretEnvelope: LocalSecretEnvelope;
}
export async function registerAgentConfigRoutes(
@@ -33,6 +38,7 @@ export async function registerAgentConfigRoutes(
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
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) => {
try {
@@ -226,8 +232,13 @@ export async function registerAgentConfigRoutes(
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const registry = createDefaultModelRegistry(process.env);
return { models: registry.listModels() };
const providerModels = await modelCatalog.listModels(auth.organization.id);
// 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) {
return handleRouteError(reply, err);
}
+1
View File
@@ -116,6 +116,7 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
prisma: config.prisma,
sessionSecret: config.sessionSecret,
skillStoreRoot: readSkillStoreRoot(),
secretEnvelope: config.secretEnvelope,
});
await registerSessionsAndUsageRoutes(app, {
prisma: config.prisma,
+144
View File
@@ -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"),
}));
}
}
+7 -12
View File
@@ -182,12 +182,16 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
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 enabledModels = new Set(defaults.listModels().map((model) => model.id));
const fallbackModels = defaults.listModels();
const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({
id: role.roleId,
label: role.label,
defaultModel: validateRoleModel(role.roleId, role.defaultModel, enabledModels),
defaultModel: role.defaultModel ?? undefined,
...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}),
...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}),
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> {
@@ -225,15 +229,6 @@ function roleToolsFromJson(roleId: string, value: Prisma.JsonValue): readonly st
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(
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");
});
});