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
+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,