From 1110d96769ae1fe74c5d0c44a62c46a57befc731 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 22:34:25 +0100 Subject: [PATCH] feat(gateway): add config toggle for chat completions endpoint --- docs/gateway/configuration.md | 1 + docs/gateway/openai-http-api.md | 16 ++++++++++++++++ src/config/schema.ts | 4 ++++ src/config/types.ts | 17 +++++++++++++++++ src/config/zod-schema.ts | 13 +++++++++++++ src/gateway/openai-http.e2e.test.ts | 22 +++++++++++++++++++++- src/gateway/server-http.ts | 8 ++++++-- src/gateway/server.ts | 10 ++++++++++ 8 files changed, 88 insertions(+), 3 deletions(-) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 9f5d0a887..4040a671d 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1803,6 +1803,7 @@ Related docs: Notes: - `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). +- Disable the OpenAI-compatible endpoint with `gateway.http.endpoints.chatCompletions.enabled: false`. - Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. - Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). - `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored. diff --git a/docs/gateway/openai-http-api.md b/docs/gateway/openai-http-api.md index 13831d336..831c5f301 100644 --- a/docs/gateway/openai-http-api.md +++ b/docs/gateway/openai-http-api.md @@ -36,6 +36,22 @@ Or target a specific Clawdbot agent by header: Advanced: - `x-clawdbot-session-key: ` to fully control session routing. +## Disabling the endpoint + +Set `gateway.http.endpoints.chatCompletions.enabled` to `false`: + +```json5 +{ + gateway: { + http: { + endpoints: { + chatCompletions: { enabled: false } + } + } + } +} +``` + ## Session behavior By default the endpoint is **stateless per request** (a new session key is generated each call). diff --git a/src/config/schema.ts b/src/config/schema.ts index 8cba062dd..1a9a9b597 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -90,6 +90,8 @@ const FIELD_LABELS: Record = { "gateway.auth.token": "Gateway Token", "gateway.auth.password": "Gateway Password", "gateway.controlUi.basePath": "Control UI Base Path", + "gateway.http.endpoints.chatCompletions.enabled": + "OpenAI Chat Completions Endpoint", "gateway.reload.mode": "Config Reload Mode", "gateway.reload.debounceMs": "Config Reload Debounce (ms)", "agents.defaults.workspace": "Workspace", @@ -158,6 +160,8 @@ const FIELD_HELP: Record = { "gateway.auth.password": "Required for Tailscale funnel.", "gateway.controlUi.basePath": "Optional URL prefix where the Control UI is served (e.g. /clawdbot).", + "gateway.http.endpoints.chatCompletions.enabled": + "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: true).", "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', "gateway.reload.debounceMs": diff --git a/src/config/types.ts b/src/config/types.ts index 87ed2817f..5dde3a4bb 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1155,6 +1155,22 @@ export type GatewayReloadConfig = { debounceMs?: number; }; +export type GatewayHttpChatCompletionsConfig = { + /** + * If false, the Gateway will not serve `POST /v1/chat/completions`. + * Default: true when absent. + */ + enabled?: boolean; +}; + +export type GatewayHttpEndpointsConfig = { + chatCompletions?: GatewayHttpChatCompletionsConfig; +}; + +export type GatewayHttpConfig = { + endpoints?: GatewayHttpEndpointsConfig; +}; + export type GatewayConfig = { /** Single multiplexed port for Gateway WS + HTTP (default: 18789). */ port?: number; @@ -1173,6 +1189,7 @@ export type GatewayConfig = { tailscale?: GatewayTailscaleConfig; remote?: GatewayRemoteConfig; reload?: GatewayReloadConfig; + http?: GatewayHttpConfig; }; export type SkillConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 8b07744aa..50db5c8d9 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1477,6 +1477,19 @@ export const ClawdbotSchema = z debounceMs: z.number().int().min(0).optional(), }) .optional(), + http: z + .object({ + endpoints: z + .object({ + chatCompletions: z + .object({ + enabled: z.boolean().optional(), + }) + .optional(), + }) + .optional(), + }) + .optional(), }) .optional(), skills: z diff --git a/src/gateway/openai-http.e2e.test.ts b/src/gateway/openai-http.e2e.test.ts index 30ef2e92d..2a1b76a79 100644 --- a/src/gateway/openai-http.e2e.test.ts +++ b/src/gateway/openai-http.e2e.test.ts @@ -9,12 +9,16 @@ import { installGatewayTestHooks(); -async function startServer(port: number) { +async function startServer( + port: number, + opts?: { openAiChatCompletionsEnabled?: boolean }, +) { const { startGatewayServer } = await import("./server.js"); return await startGatewayServer(port, { host: "127.0.0.1", auth: { mode: "token", token: "secret" }, controlUiEnabled: false, + openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled, }); } @@ -44,6 +48,22 @@ function parseSseDataLines(text: string): string[] { } describe("OpenAI-compatible HTTP API (e2e)", () => { + it("can be disabled via config (404)", async () => { + const port = await getFreePort(); + const server = await startServer(port, { + openAiChatCompletionsEnabled: false, + }); + try { + const res = await postChatCompletions(port, { + model: "clawdbot", + messages: [{ role: "user", content: "hi" }], + }); + expect(res.status).toBe(404); + } finally { + await server.close({ reason: "test done" }); + } + }); + it("rejects non-POST", async () => { const port = await getFreePort(); const server = await startServer(port); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index dbd4105b1..4d6716c33 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -206,6 +206,7 @@ export function createGatewayHttpServer(opts: { canvasHost: CanvasHostHandler | null; controlUiEnabled: boolean; controlUiBasePath: string; + openAiChatCompletionsEnabled: boolean; handleHooksRequest: HooksRequestHandler; resolvedAuth: import("./auth.js").ResolvedGatewayAuth; }): HttpServer { @@ -213,6 +214,7 @@ export function createGatewayHttpServer(opts: { canvasHost, controlUiEnabled, controlUiBasePath, + openAiChatCompletionsEnabled, handleHooksRequest, resolvedAuth, } = opts; @@ -222,8 +224,10 @@ export function createGatewayHttpServer(opts: { void (async () => { if (await handleHooksRequest(req, res)) return; - if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) - return; + if (openAiChatCompletionsEnabled) { + if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) + return; + } if (canvasHost) { if (await handleA2uiHttpRequest(req, res)) return; if (await canvasHost.handleHttpRequest(req, res)) return; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 6b51c2065..4fc2008a8 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -328,6 +328,11 @@ export type GatewayServerOptions = { * Default: config `gateway.controlUi.enabled` (or true when absent). */ controlUiEnabled?: boolean; + /** + * If false, do not serve `POST /v1/chat/completions`. + * Default: config `gateway.http.endpoints.chatCompletions.enabled` (or true when absent). + */ + openAiChatCompletionsEnabled?: boolean; /** * Override gateway auth configuration (merges with config). */ @@ -432,6 +437,10 @@ export async function startGatewayServer( } const controlUiEnabled = opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true; + const openAiChatCompletionsEnabled = + opts.openAiChatCompletionsEnabled ?? + cfgAtStart.gateway?.http?.endpoints?.chatCompletions?.enabled ?? + true; const controlUiBasePath = normalizeControlUiBasePath( cfgAtStart.gateway?.controlUi?.basePath, ); @@ -615,6 +624,7 @@ export async function startGatewayServer( canvasHost, controlUiEnabled, controlUiBasePath, + openAiChatCompletionsEnabled, handleHooksRequest, resolvedAuth, });