diff --git a/hub/admin-web/src/routes/admin/org/[slug]/roles/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/roles/+page.svelte index c8211c5..9f4287d 100644 --- a/hub/admin-web/src/routes/admin/org/[slug]/roles/+page.svelte +++ b/hub/admin-web/src/routes/admin/org/[slug]/roles/+page.svelte @@ -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 { diff --git a/hub/src/admin/routes/agentConfigRoutes.ts b/hub/src/admin/routes/agentConfigRoutes.ts index 4488124..aaa6c04 100644 --- a/hub/src/admin/routes/agentConfigRoutes.ts +++ b/hub/src/admin/routes/agentConfigRoutes.ts @@ -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 { 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); } diff --git a/hub/src/admin/routes/orgRoutes.ts b/hub/src/admin/routes/orgRoutes.ts index 4e154c7..15117db 100644 --- a/hub/src/admin/routes/orgRoutes.ts +++ b/hub/src/admin/routes/orgRoutes.ts @@ -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, diff --git a/hub/src/connections/providerModelCatalog.ts b/hub/src/connections/providerModelCatalog.ts new file mode 100644 index 0000000..26a5a75 --- /dev/null +++ b/hub/src/connections/providerModelCatalog.ts @@ -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(); + 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 { + 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 { + 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 = { + 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"), + })); + } +} diff --git a/hub/src/settings/runtime.ts b/hub/src/settings/runtime.ts index c9a06b7..015b357 100644 --- a/hub/src/settings/runtime.ts +++ b/hub/src/settings/runtime.ts @@ -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 { @@ -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 | 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, diff --git a/hub/test/unit/provider-model-catalog.test.ts b/hub/test/unit/provider-model-catalog.test.ts new file mode 100644 index 0000000..9aa23e0 --- /dev/null +++ b/hub/test/unit/provider-model-catalog.test.ts @@ -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; + 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"); + }); +});