Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/delete-model-provider.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions packages/trueforge/src/apis/modelProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import type { WithTransaction } from '../db/transaction';
import {
createModelProviderRoute,
deleteModelProviderRoute,
listModelProvidersRoute,
putModelProviderRoute,
} from '../routes/modelProviderRoutes';
Expand Down Expand Up @@ -116,9 +117,16 @@ export function createModelProvidersRouter<TTransaction>(deps: ModelProvidersRou
}
};

const deleteHandler: RouteHandler<typeof deleteModelProviderRoute> = 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;
}
2 changes: 2 additions & 0 deletions packages/trueforge/src/db/modelProviderStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export interface IModelProviderStore<TTransaction = never> {
upsertProvider(input: UpsertModelProviderInput, transaction?: TTransaction): Promise<ModelProviderRecord>;
/** Flattens manifests into the FQN read view for GET /models. */
listModels(tenantId: string, transaction?: TTransaction): Promise<AvailableModel[]>;
/** Permanently removes the provider row. Idempotent if already gone. */
deleteProvider(input: GetModelProviderInput, transaction?: TTransaction): Promise<void>;
}

/** Application-side flatten shared by both store implementations. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,13 @@ export class PostgresModelProviderStore implements IModelProviderStore<Transacti
async listModels(tenantId: string, transaction?: Transaction<Database>): Promise<AvailableModel[]> {
return flattenProviderModels(await this.listProviders(tenantId, transaction));
}

async deleteProvider(input: GetModelProviderInput, transaction?: Transaction<Database>): Promise<void> {
const db = transaction ?? this.#db;
await db
.deleteFrom('model_provider')
.where('tenant_id', '=', input.tenant_id)
.where('name', '=', input.name)
.execute();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,13 @@ export class SqliteModelProviderStore implements IModelProviderStore<Transaction
async listModels(tenantId: string, transaction?: Transaction<Database>): Promise<AvailableModel[]> {
return flattenProviderModels(await this.listProviders(tenantId, transaction));
}

async deleteProvider(input: GetModelProviderInput, transaction?: Transaction<Database>): Promise<void> {
const db = transaction ?? this.#db;
await db
.deleteFrom('model_provider')
.where('tenant_id', '=', input.tenant_id)
.where('name', '=', input.name)
.execute();
}
}
34 changes: 33 additions & 1 deletion packages/trueforge/src/routes/modelProviderRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.',
},
},
});
2 changes: 2 additions & 0 deletions packages/trueforge/src/schemas/modelProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 21 additions & 0 deletions packages/trueforge/tests/db/modelProviderStoreContractSuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
20 changes: 20 additions & 0 deletions packages/trueforge/tests/unit/apis/modelProviders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down