From 2faf9a86b0f3cb77a653e5cbafa775c5909185e4 Mon Sep 17 00:00:00 2001 From: Chris McKenzie Date: Sat, 29 Aug 2026 15:52:46 -0700 Subject: [PATCH] feat(model-providers): add DELETE endpoint to permanently remove a provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings > Models has a "Remove" button for each configured provider, gated on modelCatalog.deleteModelProvider — but that's never implemented (no backend route), so the button silently never renders. Same gap as the MCP connector (#494/#495) and skill (#498/#499) work. Add IModelProviderStore.deleteProvider for both Postgres and SQLite, the DELETE route, and its handler. Same pattern: idempotent, 200 with {} on success. Backend only, same reason as #495/#499: packages/trueforge-sdk is Fern-generated in CI and fork PRs cannot receive that regen commit. Frontend wiring (modelProviderCatalog.ts's deleteModelProvider) is a fast-follow once this merges and the SDK regenerates on main. Fixes #500 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MxyEPwQ73B27NTr83U69Ba --- .changeset/delete-model-provider.md | 5 +++ packages/trueforge/src/apis/modelProviders.ts | 8 +++++ .../trueforge/src/db/modelProviderStore.ts | 2 ++ .../PostgresModelProviderStore.ts | 9 +++++ .../SqliteModelProviderStore.ts | 9 +++++ .../src/routes/modelProviderRoutes.ts | 34 ++++++++++++++++++- .../trueforge/src/schemas/modelProvider.ts | 2 ++ .../db/modelProviderStoreContractSuite.ts | 21 ++++++++++++ .../tests/unit/apis/modelProviders.test.ts | 20 +++++++++++ 9 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 .changeset/delete-model-provider.md diff --git a/.changeset/delete-model-provider.md b/.changeset/delete-model-provider.md new file mode 100644 index 000000000..11c57a590 --- /dev/null +++ b/.changeset/delete-model-provider.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": minor +--- + +Add `DELETE /api/v1/settings/model-providers/{name}` to permanently remove a configured model provider. Idempotent if already gone. diff --git a/packages/trueforge/src/apis/modelProviders.ts b/packages/trueforge/src/apis/modelProviders.ts index c189ae334..3d3afeefa 100644 --- a/packages/trueforge/src/apis/modelProviders.ts +++ b/packages/trueforge/src/apis/modelProviders.ts @@ -7,6 +7,7 @@ import { import type { WithTransaction } from '../db/transaction'; import { createModelProviderRoute, + deleteModelProviderRoute, listModelProvidersRoute, putModelProviderRoute, } from '../routes/modelProviderRoutes'; @@ -116,9 +117,16 @@ export function createModelProvidersRouter(deps: ModelProvidersRou } }; + const deleteHandler: RouteHandler = async c => { + const { name } = c.req.valid('param'); + await deps.modelProviderStore.deleteProvider({ tenant_id: TENANT_ID, name }); + return c.json({}, 200); + }; + const router = new OpenAPIHono(); router.openapi(listModelProvidersRoute, listHandler); router.openapi(createModelProviderRoute, createHandler); router.openapi(putModelProviderRoute, putHandler); + router.openapi(deleteModelProviderRoute, deleteHandler); return router; } diff --git a/packages/trueforge/src/db/modelProviderStore.ts b/packages/trueforge/src/db/modelProviderStore.ts index 3197ee57c..5473d473a 100644 --- a/packages/trueforge/src/db/modelProviderStore.ts +++ b/packages/trueforge/src/db/modelProviderStore.ts @@ -62,6 +62,8 @@ export interface IModelProviderStore { upsertProvider(input: UpsertModelProviderInput, transaction?: TTransaction): Promise; /** Flattens manifests into the FQN read view for GET /models. */ listModels(tenantId: string, transaction?: TTransaction): Promise; + /** Permanently removes the provider row. Idempotent if already gone. */ + deleteProvider(input: GetModelProviderInput, transaction?: TTransaction): Promise; } /** Application-side flatten shared by both store implementations. */ diff --git a/packages/trueforge/src/db/postgres/model-provider-store/PostgresModelProviderStore.ts b/packages/trueforge/src/db/postgres/model-provider-store/PostgresModelProviderStore.ts index e27fb7b96..eb6d0a85d 100644 --- a/packages/trueforge/src/db/postgres/model-provider-store/PostgresModelProviderStore.ts +++ b/packages/trueforge/src/db/postgres/model-provider-store/PostgresModelProviderStore.ts @@ -123,4 +123,13 @@ export class PostgresModelProviderStore implements IModelProviderStore): Promise { return flattenProviderModels(await this.listProviders(tenantId, transaction)); } + + async deleteProvider(input: GetModelProviderInput, transaction?: Transaction): Promise { + const db = transaction ?? this.#db; + await db + .deleteFrom('model_provider') + .where('tenant_id', '=', input.tenant_id) + .where('name', '=', input.name) + .execute(); + } } diff --git a/packages/trueforge/src/db/sqlite/model-provider-store/SqliteModelProviderStore.ts b/packages/trueforge/src/db/sqlite/model-provider-store/SqliteModelProviderStore.ts index d6503d4f5..1ad6dd402 100644 --- a/packages/trueforge/src/db/sqlite/model-provider-store/SqliteModelProviderStore.ts +++ b/packages/trueforge/src/db/sqlite/model-provider-store/SqliteModelProviderStore.ts @@ -124,4 +124,13 @@ export class SqliteModelProviderStore implements IModelProviderStore): Promise { return flattenProviderModels(await this.listProviders(tenantId, transaction)); } + + async deleteProvider(input: GetModelProviderInput, transaction?: Transaction): Promise { + const db = transaction ?? this.#db; + await db + .deleteFrom('model_provider') + .where('tenant_id', '=', input.tenant_id) + .where('name', '=', input.name) + .execute(); + } } diff --git a/packages/trueforge/src/routes/modelProviderRoutes.ts b/packages/trueforge/src/routes/modelProviderRoutes.ts index 1c55fe26e..7651530fc 100644 --- a/packages/trueforge/src/routes/modelProviderRoutes.ts +++ b/packages/trueforge/src/routes/modelProviderRoutes.ts @@ -3,10 +3,11 @@ * Discovery catalog lives at GET /api/v1/catalogs/model-providers. * Handlers are registered in apis/modelProviders.ts. */ -import { createRoute } from '@hono/zod-openapi'; +import { createRoute, z } from '@hono/zod-openapi'; import { RequestErrorResponseSchema } from '../schemas/errors'; import { CreateModelProviderRequestSchema, + DeleteModelProviderResponseSchema, GetModelProviderResponseSchema, ListModelProvidersResponseSchema, UpdateModelProviderRequestSchema, @@ -96,3 +97,34 @@ export const putModelProviderRoute = createRoute({ }, }, }); + +const ModelProviderNameParamsSchema = z.object({ + name: z.string().min(1).describe('Model provider name.'), +}); + +export const deleteModelProviderRoute = createRoute({ + method: 'delete', + path: '/{name}', + tags: [OpenApiTag.MODELS], + summary: 'Delete a model provider', + description: 'Permanently removes the configured model provider by name. Idempotent if already gone.', + 'x-fern-sdk-group-name': ['settings', 'modelProviders'], + 'x-fern-sdk-method-name': 'delete', + request: { + params: ModelProviderNameParamsSchema, + }, + responses: { + 200: { + content: { 'application/json': { schema: DeleteModelProviderResponseSchema } }, + description: 'Model provider deleted.', + }, + 401: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'OIDC is configured and the request has no valid session cookie.', + }, + 403: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'OIDC is configured and the caller is authenticated but not an admin.', + }, + }, +}); diff --git a/packages/trueforge/src/schemas/modelProvider.ts b/packages/trueforge/src/schemas/modelProvider.ts index 56e1d6983..a78027385 100644 --- a/packages/trueforge/src/schemas/modelProvider.ts +++ b/packages/trueforge/src/schemas/modelProvider.ts @@ -211,6 +211,8 @@ export const ListModelProvidersResponseSchema = z }) .openapi('ListModelProvidersResponse'); +export const DeleteModelProviderResponseSchema = z.object({}).openapi('DeleteModelProviderResponse'); + /** Provider identity on the models list read view. */ export const AvailableModelProviderSchema = z .object({ diff --git a/packages/trueforge/tests/db/modelProviderStoreContractSuite.ts b/packages/trueforge/tests/db/modelProviderStoreContractSuite.ts index 47afa2839..c0f13df82 100644 --- a/packages/trueforge/tests/db/modelProviderStoreContractSuite.ts +++ b/packages/trueforge/tests/db/modelProviderStoreContractSuite.ts @@ -106,6 +106,27 @@ export function runModelProviderStoreContractSuite(getStore: () => IModelProvide expect(providers.every(record => record.tenant_id === TENANT)).toBe(true); }); + it('deleteProvider removes the row', async () => { + const store = getStore(); + await store.upsertProvider({ tenant_id: TENANT, name: 'anthropic', manifest: anthropic }); + + await store.deleteProvider({ tenant_id: TENANT, name: 'anthropic' }); + + await expect(store.getProvider({ tenant_id: TENANT, name: 'anthropic' })).resolves.toBeUndefined(); + }); + + it('deleteProvider is idempotent for an unknown provider and leaves other tenants untouched', async () => { + const store = getStore(); + const otherTenant = await store.upsertProvider({ + tenant_id: 'other-tenant', + name: 'anthropic', + manifest: anthropic, + }); + + await expect(store.deleteProvider({ tenant_id: TENANT, name: 'anthropic' })).resolves.toBeUndefined(); + await expect(store.getProvider({ tenant_id: 'other-tenant', name: 'anthropic' })).resolves.toEqual(otherTenant); + }); + it('stores custom providers with base_url', async () => { const store = getStore(); const created = await store.upsertProvider({ tenant_id: TENANT, name: custom.name, manifest: custom }); diff --git a/packages/trueforge/tests/unit/apis/modelProviders.test.ts b/packages/trueforge/tests/unit/apis/modelProviders.test.ts index 29247b273..493897ba8 100644 --- a/packages/trueforge/tests/unit/apis/modelProviders.test.ts +++ b/packages/trueforge/tests/unit/apis/modelProviders.test.ts @@ -400,6 +400,26 @@ describe('model-provider secret redaction and strict PUT', () => { const stored = await modelProviderStore.getProvider({ tenant_id: TENANT_ID, name: 'anthropic' }); expect(stored?.manifest.auth?.api_key).toBe(rotatedKey); }); + + it('DELETE /model-providers/{name} removes the provider', async () => { + const { settingsRouter, modelProviderStore } = await createRouters(); + await settingsRouter.request('/model-providers', putInit(anthropicBody)); + + const response = await settingsRouter.request('/model-providers/anthropic', { method: 'DELETE' }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({}); + + expect(await modelProviderStore.getProvider({ tenant_id: TENANT_ID, name: 'anthropic' })).toBeUndefined(); + const list = await settingsRouter.request('/model-providers'); + expect(await list.json()).toEqual({ data: [] }); + }); + + it('DELETE /model-providers/{name} is idempotent for an unknown provider', async () => { + const { settingsRouter } = await createRouters(); + const response = await settingsRouter.request('/model-providers/never-existed', { method: 'DELETE' }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({}); + }); }); describe('catalog presets are configurable', () => {