From bcada71e88286a5effa9ef60e01ff54ed969155f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 7 Mar 2026 13:22:56 -0600 Subject: [PATCH] feat(orchestrator): add AgentProviderConfig CRUD API --- .../agent-providers.controller.ts | 54 +++++ .../agent-providers/agent-providers.module.ts | 12 + .../agent-providers.service.spec.ts | 211 ++++++++++++++++++ .../agent-providers.service.ts | 71 ++++++ .../dto/create-agent-provider.dto.ts | 26 +++ .../dto/update-agent-provider.dto.ts | 30 +++ apps/orchestrator/src/app.module.ts | 2 + 7 files changed, 406 insertions(+) create mode 100644 apps/orchestrator/src/api/agent-providers/agent-providers.controller.ts create mode 100644 apps/orchestrator/src/api/agent-providers/agent-providers.module.ts create mode 100644 apps/orchestrator/src/api/agent-providers/agent-providers.service.spec.ts create mode 100644 apps/orchestrator/src/api/agent-providers/agent-providers.service.ts create mode 100644 apps/orchestrator/src/api/agent-providers/dto/create-agent-provider.dto.ts create mode 100644 apps/orchestrator/src/api/agent-providers/dto/update-agent-provider.dto.ts diff --git a/apps/orchestrator/src/api/agent-providers/agent-providers.controller.ts b/apps/orchestrator/src/api/agent-providers/agent-providers.controller.ts new file mode 100644 index 0000000..1a5ff88 --- /dev/null +++ b/apps/orchestrator/src/api/agent-providers/agent-providers.controller.ts @@ -0,0 +1,54 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common"; +import type { AgentProviderConfig } from "@prisma/client"; +import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard"; +import { OrchestratorThrottlerGuard } from "../../common/guards/throttler.guard"; +import { AgentProvidersService } from "./agent-providers.service"; +import { CreateAgentProviderDto } from "./dto/create-agent-provider.dto"; +import { UpdateAgentProviderDto } from "./dto/update-agent-provider.dto"; + +@Controller("agent-providers") +@UseGuards(OrchestratorApiKeyGuard, OrchestratorThrottlerGuard) +export class AgentProvidersController { + constructor(private readonly agentProvidersService: AgentProvidersService) {} + + @Get() + async list(): Promise { + return this.agentProvidersService.list(); + } + + @Get(":id") + async getById(@Param("id") id: string): Promise { + return this.agentProvidersService.getById(id); + } + + @Post() + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + async create(@Body() dto: CreateAgentProviderDto): Promise { + return this.agentProvidersService.create(dto); + } + + @Patch(":id") + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + async update( + @Param("id") id: string, + @Body() dto: UpdateAgentProviderDto + ): Promise { + return this.agentProvidersService.update(id, dto); + } + + @Delete(":id") + async delete(@Param("id") id: string): Promise { + return this.agentProvidersService.delete(id); + } +} diff --git a/apps/orchestrator/src/api/agent-providers/agent-providers.module.ts b/apps/orchestrator/src/api/agent-providers/agent-providers.module.ts new file mode 100644 index 0000000..28f25c6 --- /dev/null +++ b/apps/orchestrator/src/api/agent-providers/agent-providers.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "../../prisma/prisma.module"; +import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard"; +import { AgentProvidersController } from "./agent-providers.controller"; +import { AgentProvidersService } from "./agent-providers.service"; + +@Module({ + imports: [PrismaModule], + controllers: [AgentProvidersController], + providers: [OrchestratorApiKeyGuard, AgentProvidersService], +}) +export class AgentProvidersModule {} diff --git a/apps/orchestrator/src/api/agent-providers/agent-providers.service.spec.ts b/apps/orchestrator/src/api/agent-providers/agent-providers.service.spec.ts new file mode 100644 index 0000000..f161b13 --- /dev/null +++ b/apps/orchestrator/src/api/agent-providers/agent-providers.service.spec.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NotFoundException } from "@nestjs/common"; +import { AgentProvidersService } from "./agent-providers.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +describe("AgentProvidersService", () => { + let service: AgentProvidersService; + let prisma: { + agentProviderConfig: { + findMany: ReturnType; + findUnique: ReturnType; + create: ReturnType; + update: ReturnType; + delete: ReturnType; + }; + }; + + beforeEach(() => { + prisma = { + agentProviderConfig: { + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }; + + service = new AgentProvidersService(prisma as unknown as PrismaService); + }); + + it("lists all provider configs", async () => { + const expected = [ + { + id: "cfg-1", + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "Primary", + provider: "openai", + gatewayUrl: "https://gateway.example.com", + credentials: {}, + isActive: true, + createdAt: new Date("2026-03-07T18:00:00.000Z"), + updatedAt: new Date("2026-03-07T18:00:00.000Z"), + }, + ]; + prisma.agentProviderConfig.findMany.mockResolvedValue(expected); + + const result = await service.list(); + + expect(prisma.agentProviderConfig.findMany).toHaveBeenCalledWith({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + expect(result).toEqual(expected); + }); + + it("returns a single provider config", async () => { + const expected = { + id: "cfg-1", + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "Primary", + provider: "openai", + gatewayUrl: "https://gateway.example.com", + credentials: { apiKeyRef: "vault:openai" }, + isActive: true, + createdAt: new Date("2026-03-07T18:00:00.000Z"), + updatedAt: new Date("2026-03-07T18:00:00.000Z"), + }; + prisma.agentProviderConfig.findUnique.mockResolvedValue(expected); + + const result = await service.getById("cfg-1"); + + expect(prisma.agentProviderConfig.findUnique).toHaveBeenCalledWith({ + where: { id: "cfg-1" }, + }); + expect(result).toEqual(expected); + }); + + it("throws NotFoundException when provider config is missing", async () => { + prisma.agentProviderConfig.findUnique.mockResolvedValue(null); + + await expect(service.getById("missing")).rejects.toBeInstanceOf(NotFoundException); + }); + + it("creates a provider config with default credentials", async () => { + const created = { + id: "cfg-created", + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "New Provider", + provider: "claude", + gatewayUrl: "https://gateway.example.com", + credentials: {}, + isActive: true, + createdAt: new Date("2026-03-07T18:00:00.000Z"), + updatedAt: new Date("2026-03-07T18:00:00.000Z"), + }; + prisma.agentProviderConfig.create.mockResolvedValue(created); + + const result = await service.create({ + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "New Provider", + provider: "claude", + gatewayUrl: "https://gateway.example.com", + }); + + expect(prisma.agentProviderConfig.create).toHaveBeenCalledWith({ + data: { + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "New Provider", + provider: "claude", + gatewayUrl: "https://gateway.example.com", + credentials: {}, + }, + }); + expect(result).toEqual(created); + }); + + it("updates a provider config", async () => { + prisma.agentProviderConfig.findUnique.mockResolvedValue({ + id: "cfg-1", + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "Primary", + provider: "openai", + gatewayUrl: "https://gateway.example.com", + credentials: {}, + isActive: true, + createdAt: new Date("2026-03-07T18:00:00.000Z"), + updatedAt: new Date("2026-03-07T18:00:00.000Z"), + }); + + const updated = { + id: "cfg-1", + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "Secondary", + provider: "openai", + gatewayUrl: "https://gateway2.example.com", + credentials: { apiKeyRef: "vault:new" }, + isActive: false, + createdAt: new Date("2026-03-07T18:00:00.000Z"), + updatedAt: new Date("2026-03-07T19:00:00.000Z"), + }; + prisma.agentProviderConfig.update.mockResolvedValue(updated); + + const result = await service.update("cfg-1", { + name: "Secondary", + gatewayUrl: "https://gateway2.example.com", + credentials: { apiKeyRef: "vault:new" }, + isActive: false, + }); + + expect(prisma.agentProviderConfig.update).toHaveBeenCalledWith({ + where: { id: "cfg-1" }, + data: { + name: "Secondary", + gatewayUrl: "https://gateway2.example.com", + credentials: { apiKeyRef: "vault:new" }, + isActive: false, + }, + }); + expect(result).toEqual(updated); + }); + + it("throws NotFoundException when updating a missing provider config", async () => { + prisma.agentProviderConfig.findUnique.mockResolvedValue(null); + + await expect(service.update("missing", { name: "Updated" })).rejects.toBeInstanceOf( + NotFoundException + ); + expect(prisma.agentProviderConfig.update).not.toHaveBeenCalled(); + }); + + it("deletes a provider config", async () => { + prisma.agentProviderConfig.findUnique.mockResolvedValue({ + id: "cfg-1", + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "Primary", + provider: "openai", + gatewayUrl: "https://gateway.example.com", + credentials: {}, + isActive: true, + createdAt: new Date("2026-03-07T18:00:00.000Z"), + updatedAt: new Date("2026-03-07T18:00:00.000Z"), + }); + + const deleted = { + id: "cfg-1", + workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369", + name: "Primary", + provider: "openai", + gatewayUrl: "https://gateway.example.com", + credentials: {}, + isActive: true, + createdAt: new Date("2026-03-07T18:00:00.000Z"), + updatedAt: new Date("2026-03-07T18:00:00.000Z"), + }; + prisma.agentProviderConfig.delete.mockResolvedValue(deleted); + + const result = await service.delete("cfg-1"); + + expect(prisma.agentProviderConfig.delete).toHaveBeenCalledWith({ + where: { id: "cfg-1" }, + }); + expect(result).toEqual(deleted); + }); + + it("throws NotFoundException when deleting a missing provider config", async () => { + prisma.agentProviderConfig.findUnique.mockResolvedValue(null); + + await expect(service.delete("missing")).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.agentProviderConfig.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/orchestrator/src/api/agent-providers/agent-providers.service.ts b/apps/orchestrator/src/api/agent-providers/agent-providers.service.ts new file mode 100644 index 0000000..7b52c88 --- /dev/null +++ b/apps/orchestrator/src/api/agent-providers/agent-providers.service.ts @@ -0,0 +1,71 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import type { AgentProviderConfig, Prisma } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { CreateAgentProviderDto } from "./dto/create-agent-provider.dto"; +import { UpdateAgentProviderDto } from "./dto/update-agent-provider.dto"; + +@Injectable() +export class AgentProvidersService { + constructor(private readonly prisma: PrismaService) {} + + async list(): Promise { + return this.prisma.agentProviderConfig.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + } + + async getById(id: string): Promise { + const providerConfig = await this.prisma.agentProviderConfig.findUnique({ + where: { id }, + }); + + if (!providerConfig) { + throw new NotFoundException(`Agent provider config with id ${id} not found`); + } + + return providerConfig; + } + + async create(dto: CreateAgentProviderDto): Promise { + return this.prisma.agentProviderConfig.create({ + data: { + workspaceId: dto.workspaceId, + name: dto.name, + provider: dto.provider, + gatewayUrl: dto.gatewayUrl, + credentials: this.toJsonValue(dto.credentials ?? {}), + ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), + }, + }); + } + + async update(id: string, dto: UpdateAgentProviderDto): Promise { + await this.getById(id); + + const data: Prisma.AgentProviderConfigUpdateInput = { + ...(dto.workspaceId !== undefined ? { workspaceId: dto.workspaceId } : {}), + ...(dto.name !== undefined ? { name: dto.name } : {}), + ...(dto.provider !== undefined ? { provider: dto.provider } : {}), + ...(dto.gatewayUrl !== undefined ? { gatewayUrl: dto.gatewayUrl } : {}), + ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), + ...(dto.credentials !== undefined ? { credentials: this.toJsonValue(dto.credentials) } : {}), + }; + + return this.prisma.agentProviderConfig.update({ + where: { id }, + data, + }); + } + + async delete(id: string): Promise { + await this.getById(id); + + return this.prisma.agentProviderConfig.delete({ + where: { id }, + }); + } + + private toJsonValue(value: Record): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; + } +} diff --git a/apps/orchestrator/src/api/agent-providers/dto/create-agent-provider.dto.ts b/apps/orchestrator/src/api/agent-providers/dto/create-agent-provider.dto.ts new file mode 100644 index 0000000..432ac15 --- /dev/null +++ b/apps/orchestrator/src/api/agent-providers/dto/create-agent-provider.dto.ts @@ -0,0 +1,26 @@ +import { IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID } from "class-validator"; + +export class CreateAgentProviderDto { + @IsUUID() + workspaceId!: string; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @IsNotEmpty() + provider!: string; + + @IsString() + @IsNotEmpty() + gatewayUrl!: string; + + @IsOptional() + @IsObject() + credentials?: Record; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/orchestrator/src/api/agent-providers/dto/update-agent-provider.dto.ts b/apps/orchestrator/src/api/agent-providers/dto/update-agent-provider.dto.ts new file mode 100644 index 0000000..75e2bdc --- /dev/null +++ b/apps/orchestrator/src/api/agent-providers/dto/update-agent-provider.dto.ts @@ -0,0 +1,30 @@ +import { IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID } from "class-validator"; + +export class UpdateAgentProviderDto { + @IsOptional() + @IsUUID() + workspaceId?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + provider?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + gatewayUrl?: string; + + @IsOptional() + @IsObject() + credentials?: Record; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/orchestrator/src/app.module.ts b/apps/orchestrator/src/app.module.ts index 23c00c1..3b337cb 100644 --- a/apps/orchestrator/src/app.module.ts +++ b/apps/orchestrator/src/app.module.ts @@ -5,6 +5,7 @@ import { ThrottlerModule } from "@nestjs/throttler"; import { HealthModule } from "./api/health/health.module"; import { AgentsModule } from "./api/agents/agents.module"; import { QueueApiModule } from "./api/queue/queue-api.module"; +import { AgentProvidersModule } from "./api/agent-providers/agent-providers.module"; import { CoordinatorModule } from "./coordinator/coordinator.module"; import { BudgetModule } from "./budget/budget.module"; import { CIModule } from "./ci"; @@ -51,6 +52,7 @@ import { orchestratorConfig } from "./config/orchestrator.config"; ]), HealthModule, AgentsModule, + AgentProvidersModule, QueueApiModule, CoordinatorModule, BudgetModule,