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
+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);
}