diff --git a/.changeset/model-provider-deletion.md b/.changeset/model-provider-deletion.md new file mode 100644 index 000000000..4bfb1896b --- /dev/null +++ b/.changeset/model-provider-deletion.md @@ -0,0 +1,7 @@ +--- +'@truefoundry/trueforge': minor +'@truefoundry/trueforge-sdk': patch +'@truefoundry/trueforge-ui': patch +--- + +Add model-provider deletion via the settings API, SDK, and UI adapter. diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 68e38c3cf..5adcc3142 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -1036,6 +1036,10 @@ "properties": {}, "type": "object" }, + "DeleteModelProviderResponse": { + "properties": {}, + "type": "object" + }, "DynamicSubAgentsConfig": { "default": { "enabled": true @@ -6663,6 +6667,55 @@ "x-fern-sdk-method-name": "create_or_update" } }, + "/api/v1/settings/model-providers/{name}": { + "delete": { + "description": "Deletes a configured model provider by name.", + "parameters": [ + { + "description": "Model provider name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Model provider name.", + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteModelProviderResponse" + } + } + }, + "description": "The model provider was deleted." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Model provider not found." + } + }, + "summary": "Delete a model provider", + "tags": [ + "Models" + ], + "x-fern-sdk-group-name": [ + "settings", + "modelProviders" + ], + "x-fern-sdk-method-name": "delete" + } + }, "/api/v1/settings/sandbox-providers": { "get": { "description": "The single configured sandbox provider for this tenant. `auth.api_key` is redacted.", diff --git a/docs/openapi.json b/docs/openapi.json index 68e38c3cf..5adcc3142 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1036,6 +1036,10 @@ "properties": {}, "type": "object" }, + "DeleteModelProviderResponse": { + "properties": {}, + "type": "object" + }, "DynamicSubAgentsConfig": { "default": { "enabled": true @@ -6663,6 +6667,55 @@ "x-fern-sdk-method-name": "create_or_update" } }, + "/api/v1/settings/model-providers/{name}": { + "delete": { + "description": "Deletes a configured model provider by name.", + "parameters": [ + { + "description": "Model provider name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Model provider name.", + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteModelProviderResponse" + } + } + }, + "description": "The model provider was deleted." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Model provider not found." + } + }, + "summary": "Delete a model provider", + "tags": [ + "Models" + ], + "x-fern-sdk-group-name": [ + "settings", + "modelProviders" + ], + "x-fern-sdk-method-name": "delete" + } + }, "/api/v1/settings/sandbox-providers": { "get": { "description": "The single configured sandbox provider for this tenant. `auth.api_key` is redacted.", diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index 2787f170e..fd507949d 100644 --- a/packages/trueforge-sdk/reference.md +++ b/packages/trueforge-sdk/reference.md @@ -2497,6 +2497,69 @@ await client.settings.modelProviders.createOrUpdate({ + + + + +
client.settings.modelProviders.delete(name) -> void +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Deletes a configured model provider by name. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.settings.modelProviders.delete("name"); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**name:** `string` — Model provider name. + +
+
+ +
+
+ +**requestOptions:** `ModelProvidersClient.RequestOptions` + +
+
+
+
+ +
diff --git a/packages/trueforge-sdk/src/api/resources/settings/resources/modelProviders/client/Client.ts b/packages/trueforge-sdk/src/api/resources/settings/resources/modelProviders/client/Client.ts index a3fdeef0e..eaf840f3d 100644 --- a/packages/trueforge-sdk/src/api/resources/settings/resources/modelProviders/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/settings/resources/modelProviders/client/Client.ts @@ -353,4 +353,80 @@ export class ModelProvidersClient { "/api/v1/settings/model-providers", ); } + + /** + * Deletes a configured model provider by name. + * + * @param {string} name - Model provider name. + * @param {ModelProvidersClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link TrueForge.NotFoundError} + * @throws {@link errors.TrueForgeError} + * @throws {@link errors.TrueForgeTimeoutError} + * + * @example + * await client.settings.modelProviders.delete("name") + */ + public delete(name: string, requestOptions?: ModelProvidersClient.RequestOptions): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__delete(name, requestOptions)); + } + + private async __delete( + name: string, + requestOptions?: ModelProvidersClient.RequestOptions, + ): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + `api/v1/settings/model-providers/${core.url.encodePathParam(name)}`, + ), + method: "DELETE", + headers: _headers, + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: undefined, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 404: + throw new TrueForge.NotFoundError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + default: + throw new errors.TrueForgeError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "DELETE", + "/api/v1/settings/model-providers/{name}", + ); + } } diff --git a/packages/trueforge-sdk/tests/tsconfig.json b/packages/trueforge-sdk/tests/tsconfig.json index ac39744de..b2cf8f32e 100644 --- a/packages/trueforge-sdk/tests/tsconfig.json +++ b/packages/trueforge-sdk/tests/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": null, "rootDir": "..", - "types": ["vitest/globals"] + "types": ["node", "vitest/globals"] }, "include": ["../src", "../tests"], "exclude": [] diff --git a/packages/trueforge-sdk/tests/wire/settings/modelProviders.test.ts b/packages/trueforge-sdk/tests/wire/settings/modelProviders.test.ts index 666adb3fb..37d030283 100644 --- a/packages/trueforge-sdk/tests/wire/settings/modelProviders.test.ts +++ b/packages/trueforge-sdk/tests/wire/settings/modelProviders.test.ts @@ -368,4 +368,33 @@ describe("ModelProvidersClient", () => { }); }).rejects.toThrow(TrueForgeTypes.BadRequestError); }); + + test("delete (1)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + server.mockEndpoint().delete("/api/v1/settings/model-providers/name").respondWith().statusCode(200).build(); + + const response = await client.settings.modelProviders.delete("name"); + expect(response).toEqual(undefined); + }); + + test("delete (2)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .delete("/api/v1/settings/model-providers/name") + .respondWith() + .statusCode(404) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.settings.modelProviders.delete("name"); + }).rejects.toThrow(TrueForgeTypes.NotFoundError); + }); }); diff --git a/packages/trueforge-ui/src/containers/SettingsBuilder/ModelSettings.tsx b/packages/trueforge-ui/src/containers/SettingsBuilder/ModelSettings.tsx index 8836278d6..dcf004504 100644 --- a/packages/trueforge-ui/src/containers/SettingsBuilder/ModelSettings.tsx +++ b/packages/trueforge-ui/src/containers/SettingsBuilder/ModelSettings.tsx @@ -391,7 +391,7 @@ const ModelSettings = () => { onClick={() => { handleUpdateModels( provider, - provider.models.filter(item => item.id !== model.id), + provider.models.filter(m => m.id !== model.id), ); }} > diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/modelProviderCatalog.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/modelProviderCatalog.ts index eac2c6154..fb3b75b02 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/modelProviderCatalog.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/modelProviderCatalog.ts @@ -175,5 +175,8 @@ export function createModelProviderCatalog( }); return toUiModelProvider(body.data); }, + deleteModelProvider: async ({ id }) => { + await client.settings.modelProviders.delete(id); + }, }; } diff --git a/packages/trueforge-ui/test/containers/SettingsBuilder/ModelSettings.test.tsx b/packages/trueforge-ui/test/containers/SettingsBuilder/ModelSettings.test.tsx index a7086ad6e..7c960828d 100644 --- a/packages/trueforge-ui/test/containers/SettingsBuilder/ModelSettings.test.tsx +++ b/packages/trueforge-ui/test/containers/SettingsBuilder/ModelSettings.test.tsx @@ -100,4 +100,57 @@ describe('ModelSettings custom provider editing', () => { models: customProvider.models, }); }); + + it('removes only the selected model when clicking model trash icon without deleting provider', async () => { + const multiModelProvider: ModelProviderBase = { + id: 'openai', + type: 'openai', + name: 'OpenAI', + models: [ + { id: 'gpt-4.1', name: 'gpt-4-1' }, + { id: 'gpt-5-mini', name: 'gpt-5-mini' }, + ], + }; + const updateModelProvider = vi.fn(async () => multiModelProvider); + const deleteModelProvider = vi.fn(async () => {}); + const server = createMockAgentUIServer({ + catalog: createMockCatalog({ + modelCatalog: { + getModelProviderCatalog: async () => [ + { + type: 'openai', + name: 'OpenAI', + models: [ + { id: 'gpt-4.1', name: 'gpt-4-1' }, + { id: 'gpt-5-mini', name: 'gpt-5-mini' }, + ], + }, + ], + listModelProviders: async () => [multiModelProvider], + createModelProvider: vi.fn(), + updateModelProvider, + deleteModelProvider, + }, + }), + }); + + render( + + + , + ); + + const removeModelButton = await screen.findByRole('button', { name: 'Remove gpt-4-1' }); + fireEvent.click(removeModelButton); + + await waitFor(() => expect(updateModelProvider).toHaveBeenCalledTimes(1)); + expect(updateModelProvider).toHaveBeenCalledWith({ + id: 'openai', + type: 'openai', + name: 'OpenAI', + apiKey: '', + models: [{ id: 'gpt-5-mini', name: 'gpt-5-mini' }], + }); + expect(deleteModelProvider).not.toHaveBeenCalled(); + }); }); diff --git a/packages/trueforge/src/apis/modelProviders.ts b/packages/trueforge/src/apis/modelProviders.ts index c189ae334..a461b8b26 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'; @@ -64,6 +65,21 @@ function toWireProvider(record: ModelProviderRecord): ConfiguredModelProvider { } export function createModelProvidersRouter(deps: ModelProvidersRouterDeps) { + const deleteHandler: RouteHandler = async c => { + const { name } = c.req.valid('param'); + + const deleted = await deps.modelProviderStore.deleteProvider({ + tenant_id: TENANT_ID, + name, + }); + + if (!deleted) { + return c.json({ error: { message: `Model provider "${name}" not found` } }, 404); + } + + return c.json({}, 200); + }; + const listHandler: RouteHandler = async c => { const records = await deps.modelProviderStore.listProviders(TENANT_ID); return c.json({ data: records.map(toWireProvider) }, 200); @@ -120,5 +136,6 @@ export function createModelProvidersRouter(deps: ModelProvidersRou 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..53dc27367 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; + + 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..d75c71727 100644 --- a/packages/trueforge/src/db/postgres/model-provider-store/PostgresModelProviderStore.ts +++ b/packages/trueforge/src/db/postgres/model-provider-store/PostgresModelProviderStore.ts @@ -120,6 +120,18 @@ export class PostgresModelProviderStore implements IModelProviderStore): Promise { + const db = transaction ?? this.#db; + + const result = await db + .deleteFrom('model_provider') + .where('tenant_id', '=', input.tenant_id) + .where('name', '=', input.name) + .executeTakeFirst(); + + return Number(result.numDeletedRows) > 0; + } + async listModels(tenantId: string, transaction?: Transaction): Promise { return flattenProviderModels(await this.listProviders(tenantId, transaction)); } 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..2e04c7c2b 100644 --- a/packages/trueforge/src/db/sqlite/model-provider-store/SqliteModelProviderStore.ts +++ b/packages/trueforge/src/db/sqlite/model-provider-store/SqliteModelProviderStore.ts @@ -121,6 +121,18 @@ export class SqliteModelProviderStore implements IModelProviderStore): Promise { + const db = transaction ?? this.#db; + + const result = await db + .deleteFrom('model_provider') + .where('tenant_id', '=', input.tenant_id) + .where('name', '=', input.name) + .executeTakeFirst(); + + return Number(result.numDeletedRows) > 0; + } + async listModels(tenantId: string, transaction?: Transaction): Promise { return flattenProviderModels(await this.listProviders(tenantId, transaction)); } diff --git a/packages/trueforge/src/routes/modelProviderRoutes.ts b/packages/trueforge/src/routes/modelProviderRoutes.ts index 1c55fe26e..339caebca 100644 --- a/packages/trueforge/src/routes/modelProviderRoutes.ts +++ b/packages/trueforge/src/routes/modelProviderRoutes.ts @@ -3,16 +3,21 @@ * 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, } from '../schemas/modelProvider'; import { OpenApiTag } from './openapiTags'; +const ModelProviderNameParamsSchema = z.object({ + name: z.string().min(1).describe('Model provider name.'), +}); + export const listModelProvidersRoute = createRoute({ method: 'get', path: '/', @@ -96,3 +101,26 @@ export const putModelProviderRoute = createRoute({ }, }, }); + +export const deleteModelProviderRoute = createRoute({ + method: 'delete', + path: '/{name}', + tags: [OpenApiTag.MODELS], + summary: 'Delete a model provider', + description: 'Deletes a configured model provider by name.', + 'x-fern-sdk-group-name': ['settings', 'modelProviders'], + 'x-fern-sdk-method-name': 'delete', + request: { + params: ModelProviderNameParamsSchema, + }, + responses: { + 200: { + content: { 'application/json': { schema: DeleteModelProviderResponseSchema } }, + description: 'The model provider was deleted.', + }, + 404: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'Model provider not found.', + }, + }, +}); 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..722e20239 100644 --- a/packages/trueforge/tests/db/modelProviderStoreContractSuite.ts +++ b/packages/trueforge/tests/db/modelProviderStoreContractSuite.ts @@ -134,4 +134,29 @@ export function runModelProviderStoreContractSuite(getStore: () => IModelProvide }, ]); }); + + it('deleteProvider deletes the provider and returns true, or false if not found', async () => { + const store = getStore(); + await store.upsertProvider({ tenant_id: TENANT, name: 'anthropic', manifest: anthropic }); + + const deleted = await store.deleteProvider({ tenant_id: TENANT, name: 'anthropic' }); + expect(deleted).toBe(true); + + const fetched = await store.getProvider({ tenant_id: TENANT, name: 'anthropic' }); + expect(fetched).toBeUndefined(); + + const deletedAgain = await store.deleteProvider({ tenant_id: TENANT, name: 'anthropic' }); + expect(deletedAgain).toBe(false); + }); + + it('deleteProvider is tenant scoped', async () => { + const store = getStore(); + await store.upsertProvider({ tenant_id: 'other-tenant', name: 'anthropic', manifest: anthropic }); + + const deleted = await store.deleteProvider({ tenant_id: TENANT, name: 'anthropic' }); + expect(deleted).toBe(false); + + const otherTenantProvider = await store.getProvider({ tenant_id: 'other-tenant', name: 'anthropic' }); + expect(otherTenantProvider).toBeDefined(); + }); } diff --git a/packages/trueforge/tests/unit/apis/modelProviders.test.ts b/packages/trueforge/tests/unit/apis/modelProviders.test.ts index 29247b273..cb7984d8b 100644 --- a/packages/trueforge/tests/unit/apis/modelProviders.test.ts +++ b/packages/trueforge/tests/unit/apis/modelProviders.test.ts @@ -421,3 +421,25 @@ describe('catalog presets are configurable', () => { expect(json.data.manifest.auth.api_key).toBe(toRedactedSecretValue(`sk-${preset.type}`)); }); }); + +describe('DELETE /model-providers/:name', () => { + it('deletes a configured provider and returns 200 with empty body', async () => { + const { settingsRouter, modelProviderStore } = await createRouters(); + expect((await settingsRouter.request('/model-providers', putInit(anthropicBody))).status).toBe(200); + + const deleteResponse = await settingsRouter.request('/model-providers/anthropic', { method: 'DELETE' }); + expect(deleteResponse.status).toBe(200); + expect(await deleteResponse.json()).toEqual({}); + + const stored = await modelProviderStore.getProvider({ tenant_id: TENANT_ID, name: 'anthropic' }); + expect(stored).toBeUndefined(); + }); + + it('returns 404 when deleting a non-existent provider', async () => { + const { settingsRouter } = await createRouters(); + const deleteResponse = await settingsRouter.request('/model-providers/non-existent', { method: 'DELETE' }); + expect(deleteResponse.status).toBe(404); + const json = (await deleteResponse.json()) as { error: { message: string } }; + expect(json.error.message).toContain('not found'); + }); +});