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/quiet-pens-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'manifest': patch
---

Add a signed GitHub webhook endpoint to refresh the model parameters schema cache after modelparameters.dev PRs are merged.
16 changes: 15 additions & 1 deletion docs/model-parameters-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,21 @@ can configure for a provider/auth/model tuple. User-selected values still live i

The runtime source is `https://modelparameters.dev/api/v1/models.json`.
Manifest caches the latest valid remote catalog in memory and keeps that cache
through transient refresh failures.
through transient refresh failures. Runtime fetches add a cache-busting query
parameter so webhook-triggered refreshes are not delayed by CDN cache TTLs.

Production can refresh the cache immediately from a GitHub webhook on the
`mnfst/modelparameters.dev` repository. Configure a GitHub Pull request webhook
with:

- Payload URL: `https://<manifest-host>/api/v1/webhooks/model-parameters/github`
- Content type: `application/json`
- Secret: the same value as Manifest's `MODEL_PARAMETERS_WEBHOOK_SECRET`
- Events: Pull requests

Manifest verifies `X-Hub-Signature-256` and refreshes only when a pull request
is closed, merged, targets `main`, and belongs to `mnfst/modelparameters.dev`.
The daily 4am refresh remains the fallback.

The executable validator is `isParamApplicability` in
`packages/shared/src/provider-params-spec.ts`. Any schema change must update:
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/config/app.config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ describe('appConfig', () => {
expect(config.betterAuthUrl).toBe('https://auth.example.com');
});

it('reads MODEL_PARAMETERS_WEBHOOK_SECRET from env', async () => {
process.env['MODEL_PARAMETERS_WEBHOOK_SECRET'] = 'webhook-secret';
const config = await loadConfig();
expect(config.modelParametersWebhookSecret).toBe('webhook-secret');
});

it('defaults throttle settings', async () => {
delete process.env['THROTTLE_TTL'];
delete process.env['THROTTLE_LIMIT'];
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/config/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const appConfig = registerAs('app', () => ({
throttleTtl: Number(process.env['THROTTLE_TTL'] ?? 60000),
throttleLimit: Number(process.env['THROTTLE_LIMIT'] ?? 100),
apiKey: process.env['API_KEY'] ?? '',
modelParametersWebhookSecret: process.env['MODEL_PARAMETERS_WEBHOOK_SECRET'] ?? '',
bindAddress: process.env['BIND_ADDRESS'] ?? '127.0.0.1',
// Unified email provider (used for BOTH Better Auth transactional emails
// and threshold alerts when no per-user config exists). Supports mailgun,
Expand Down
14 changes: 12 additions & 2 deletions packages/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,18 @@ export async function bootstrap() {
const { toNodeHandler } = await import('better-auth/node');
expressApp.all('/api/auth/*splat', toNodeHandler(auth));

// Re-add body parsing for NestJS routes
expressApp.use(express.json({ limit: '1mb' }));
// Re-add body parsing for NestJS routes. Keep the raw JSON bytes only
// when a webhook signature needs to be checked.
expressApp.use(
express.json({
limit: '1mb',
verify: (req, _res, buf) => {
if (req.headers['x-hub-signature-256']) {
(req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
}
},
}),
);
expressApp.use(express.urlencoded({ extended: true, limit: '1mb' }));

const port = Number(process.env['PORT'] ?? 3001);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { ConfigService } from '@nestjs/config';
import { UnauthorizedException, ServiceUnavailableException } from '@nestjs/common';
import { createHmac } from 'crypto';
import type { Request } from 'express';
import { ModelParametersWebhookController } from '../model-parameters-webhook.controller';
import { ProviderParamSpecService } from '../routing-core/provider-param-spec.service';

type RawBodyRequest = Request & { rawBody?: Buffer };

describe('ModelParametersWebhookController', () => {
const secret = 'webhook-secret';
let controller: ModelParametersWebhookController;
let providerParamSpecs: Pick<ProviderParamSpecService, 'refreshCache' | 'getLastFetchedAt'>;
let config: Pick<ConfigService, 'get'>;

beforeEach(() => {
providerParamSpecs = {
refreshCache: jest.fn().mockResolvedValue(59),
getLastFetchedAt: jest.fn().mockReturnValue(new Date('2026-05-20T12:00:00.000Z')),
};
config = {
get: jest.fn().mockReturnValue(secret),
};
controller = new ModelParametersWebhookController(
providerParamSpecs as ProviderParamSpecService,
config as ConfigService,
);
});

function signedRequest(payload: unknown): {
body: unknown;
request: RawBodyRequest;
signature: string;
} {
const rawBody = Buffer.from(JSON.stringify(payload));
return {
body: payload,
request: { rawBody } as RawBodyRequest,
signature: 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'),
};
}

function mergedModelParametersPayload() {
return {
action: 'closed',
repository: { full_name: 'mnfst/modelparameters.dev' },
pull_request: {
merged: true,
base: { ref: 'main' },
},
};
}

it('refreshes the MPS cache for a merged modelparameters.dev PR', async () => {
const { body, request, signature } = signedRequest(mergedModelParametersPayload());

await expect(
controller.handleGithubWebhook(request, signature, 'pull_request', body),
).resolves.toEqual({
ok: true,
refreshed: true,
model_count: 59,
last_fetched_at: '2026-05-20T12:00:00.000Z',
});
expect(providerParamSpecs.refreshCache).toHaveBeenCalledTimes(1);
});

it('ignores non-merged pull request events', async () => {
const { body, request, signature } = signedRequest({
...mergedModelParametersPayload(),
action: 'opened',
});

await expect(
controller.handleGithubWebhook(request, signature, 'pull_request', body),
).resolves.toEqual({
ok: true,
ignored: true,
reason: 'not_modelparameters_merge',
});
expect(providerParamSpecs.refreshCache).not.toHaveBeenCalled();
});

it('ignores unsupported GitHub events', async () => {
const { body, request, signature } = signedRequest({ zen: 'Keep it logically awesome.' });

await expect(controller.handleGithubWebhook(request, signature, 'ping', body)).resolves.toEqual(
{
ok: true,
ignored: true,
reason: 'unsupported_event',
},
);
expect(providerParamSpecs.refreshCache).not.toHaveBeenCalled();
});

it('rejects invalid webhook signatures', async () => {
const { body, request } = signedRequest(mergedModelParametersPayload());

await expect(
controller.handleGithubWebhook(request, 'sha256=bad', 'pull_request', body),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(providerParamSpecs.refreshCache).not.toHaveBeenCalled();
});

it('fails closed when no webhook secret is configured', async () => {
(config.get as jest.Mock).mockReturnValue('');
const { body, request, signature } = signedRequest(mergedModelParametersPayload());

await expect(
controller.handleGithubWebhook(request, signature, 'pull_request', body),
).rejects.toBeInstanceOf(ServiceUnavailableException);
expect(providerParamSpecs.refreshCache).not.toHaveBeenCalled();
});
});
104 changes: 104 additions & 0 deletions packages/backend/src/routing/model-parameters-webhook.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
BadRequestException,
Body,
Controller,
Headers,
HttpCode,
Post,
Req,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual } from 'crypto';
import type { Request } from 'express';
import { Public } from '../common/decorators/public.decorator';
import { ProviderParamSpecService } from './routing-core/provider-param-spec.service';

const GITHUB_SIGNATURE_PREFIX = 'sha256=';
const MODELPARAMETERS_REPO = 'mnfst/modelparameters.dev';
const MODELPARAMETERS_BASE_BRANCH = 'main';

type RawBodyRequest = Request & { rawBody?: Buffer };

@Controller('api/v1/webhooks/model-parameters')
export class ModelParametersWebhookController {
constructor(
private readonly providerParamSpecs: ProviderParamSpecService,
private readonly config: ConfigService,
) {}

@Public()
@Post('github')
@HttpCode(200)
async handleGithubWebhook(
@Req() request: RawBodyRequest,
@Headers('x-hub-signature-256') signature: string | string[] | undefined,
@Headers('x-github-event') event: string | string[] | undefined,
@Body() body: unknown,
) {
this.assertValidSignature(request.rawBody, signature);

if (event !== 'pull_request') {
return { ok: true, ignored: true, reason: 'unsupported_event' };
}

if (!isMergedModelParametersPullRequest(body)) {
return { ok: true, ignored: true, reason: 'not_modelparameters_merge' };
}

const modelCount = await this.providerParamSpecs.refreshCache();
return {
ok: modelCount > 0,
refreshed: modelCount > 0,
model_count: modelCount,
last_fetched_at: this.providerParamSpecs.getLastFetchedAt()?.toISOString() ?? null,
};
}

private assertValidSignature(
rawBody: Buffer | undefined,
signature: string | string[] | undefined,
): void {
const secret = this.config.get<string>('app.modelParametersWebhookSecret', '');
if (!secret) {
throw new ServiceUnavailableException('Model parameters webhook is not configured');
}
if (typeof signature !== 'string' || !signature.startsWith(GITHUB_SIGNATURE_PREFIX)) {
throw new UnauthorizedException('Invalid webhook signature');
}
if (!rawBody) {
throw new BadRequestException('Missing raw request body');
}

const expected =
GITHUB_SIGNATURE_PREFIX + createHmac('sha256', secret).update(rawBody).digest('hex');
if (!safeCompare(signature, expected)) {
throw new UnauthorizedException('Invalid webhook signature');
}
}
}

function isMergedModelParametersPullRequest(body: unknown): boolean {
if (!isRecord(body)) return false;
if (body.action !== 'closed') return false;

const repository = isRecord(body.repository) ? body.repository : null;
if (repository?.full_name !== MODELPARAMETERS_REPO) return false;

const pullRequest = isRecord(body.pull_request) ? body.pull_request : null;
if (pullRequest?.merged !== true) return false;

const base = isRecord(pullRequest.base) ? pullRequest.base : null;
return base?.ref === MODELPARAMETERS_BASE_BRANCH;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function safeCompare(a: string, b: string): boolean {
const aBuf = Buffer.from(a, 'utf8');
const bBuf = Buffer.from(b, 'utf8');
return aBuf.length === bBuf.length && timingSafeEqual(aBuf, bBuf);
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe('ProviderParamSpecService', () => {
await expect(service.refreshCache()).resolves.toBe(2);

expect(fetchSpy).toHaveBeenCalledWith(
'https://modelparameters.dev/api/v1/models.json',
expect.stringMatching(/^https:\/\/modelparameters\.dev\/api\/v1\/models\.json\?refresh=\d+$/),
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
const remoteSpecs = await service.getSpecs('openai', 'api_key', 'gpt-test');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export class ProviderParamSpecService implements OnModuleInit {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(MODEL_PARAMETERS_API, { signal: controller.signal });
const res = await fetch(modelParametersApiUrl(), { signal: controller.signal });
if (!res.ok) {
this.logger.warn(`modelparameters.dev API returned ${res.status}`);
return null;
Expand All @@ -110,6 +110,12 @@ export class ProviderParamSpecService implements OnModuleInit {
}
}

function modelParametersApiUrl(): string {
const url = new URL(MODEL_PARAMETERS_API);
url.searchParams.set('refresh', String(Date.now()));
return url.toString();
}

function freezeCatalog(catalog: ProviderParamSpecCatalog): ProviderParamSpecCatalog {
return Object.freeze(
catalog
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/routing/routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ModelController } from './model.controller';
import { CopilotController } from './copilot.controller';
import { SpecificityController } from './specificity.controller';
import { ModelParamsController } from './model-params.controller';
import { ModelParametersWebhookController } from './model-parameters-webhook.controller';
import { OllamaSyncService } from '../database/ollama-sync.service';
import { NotificationsModule } from '../notifications/notifications.module';

Expand All @@ -37,6 +38,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
CopilotController,
SpecificityController,
ModelParamsController,
ModelParametersWebhookController,
],
providers: [OllamaSyncService],
exports: [RoutingCoreModule, CustomProviderModule, OAuthModule],
Expand Down
Loading