feat(gateway): add config toggle for chat completions endpoint

This commit is contained in:
Peter Steinberger
2026-01-10 22:34:25 +01:00
parent 050c1c5391
commit 1110d96769
8 changed files with 88 additions and 3 deletions

View File

@@ -1803,6 +1803,7 @@ Related docs:
Notes: Notes:
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `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). - `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`. - 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`). - 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. - `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.

View File

@@ -36,6 +36,22 @@ Or target a specific Clawdbot agent by header:
Advanced: Advanced:
- `x-clawdbot-session-key: <sessionKey>` to fully control session routing. - `x-clawdbot-session-key: <sessionKey>` 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 ## Session behavior
By default the endpoint is **stateless per request** (a new session key is generated each call). By default the endpoint is **stateless per request** (a new session key is generated each call).

View File

@@ -90,6 +90,8 @@ const FIELD_LABELS: Record<string, string> = {
"gateway.auth.token": "Gateway Token", "gateway.auth.token": "Gateway Token",
"gateway.auth.password": "Gateway Password", "gateway.auth.password": "Gateway Password",
"gateway.controlUi.basePath": "Control UI Base Path", "gateway.controlUi.basePath": "Control UI Base Path",
"gateway.http.endpoints.chatCompletions.enabled":
"OpenAI Chat Completions Endpoint",
"gateway.reload.mode": "Config Reload Mode", "gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)", "gateway.reload.debounceMs": "Config Reload Debounce (ms)",
"agents.defaults.workspace": "Workspace", "agents.defaults.workspace": "Workspace",
@@ -158,6 +160,8 @@ const FIELD_HELP: Record<string, string> = {
"gateway.auth.password": "Required for Tailscale funnel.", "gateway.auth.password": "Required for Tailscale funnel.",
"gateway.controlUi.basePath": "gateway.controlUi.basePath":
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).", "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": "gateway.reload.mode":
'Hot reload strategy for config changes ("hybrid" recommended).', 'Hot reload strategy for config changes ("hybrid" recommended).',
"gateway.reload.debounceMs": "gateway.reload.debounceMs":

View File

@@ -1155,6 +1155,22 @@ export type GatewayReloadConfig = {
debounceMs?: number; 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 = { export type GatewayConfig = {
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */ /** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
port?: number; port?: number;
@@ -1173,6 +1189,7 @@ export type GatewayConfig = {
tailscale?: GatewayTailscaleConfig; tailscale?: GatewayTailscaleConfig;
remote?: GatewayRemoteConfig; remote?: GatewayRemoteConfig;
reload?: GatewayReloadConfig; reload?: GatewayReloadConfig;
http?: GatewayHttpConfig;
}; };
export type SkillConfig = { export type SkillConfig = {

View File

@@ -1477,6 +1477,19 @@ export const ClawdbotSchema = z
debounceMs: z.number().int().min(0).optional(), debounceMs: z.number().int().min(0).optional(),
}) })
.optional(), .optional(),
http: z
.object({
endpoints: z
.object({
chatCompletions: z
.object({
enabled: z.boolean().optional(),
})
.optional(),
})
.optional(),
})
.optional(),
}) })
.optional(), .optional(),
skills: z skills: z

View File

@@ -9,12 +9,16 @@ import {
installGatewayTestHooks(); installGatewayTestHooks();
async function startServer(port: number) { async function startServer(
port: number,
opts?: { openAiChatCompletionsEnabled?: boolean },
) {
const { startGatewayServer } = await import("./server.js"); const { startGatewayServer } = await import("./server.js");
return await startGatewayServer(port, { return await startGatewayServer(port, {
host: "127.0.0.1", host: "127.0.0.1",
auth: { mode: "token", token: "secret" }, auth: { mode: "token", token: "secret" },
controlUiEnabled: false, controlUiEnabled: false,
openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled,
}); });
} }
@@ -44,6 +48,22 @@ function parseSseDataLines(text: string): string[] {
} }
describe("OpenAI-compatible HTTP API (e2e)", () => { 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 () => { it("rejects non-POST", async () => {
const port = await getFreePort(); const port = await getFreePort();
const server = await startServer(port); const server = await startServer(port);

View File

@@ -206,6 +206,7 @@ export function createGatewayHttpServer(opts: {
canvasHost: CanvasHostHandler | null; canvasHost: CanvasHostHandler | null;
controlUiEnabled: boolean; controlUiEnabled: boolean;
controlUiBasePath: string; controlUiBasePath: string;
openAiChatCompletionsEnabled: boolean;
handleHooksRequest: HooksRequestHandler; handleHooksRequest: HooksRequestHandler;
resolvedAuth: import("./auth.js").ResolvedGatewayAuth; resolvedAuth: import("./auth.js").ResolvedGatewayAuth;
}): HttpServer { }): HttpServer {
@@ -213,6 +214,7 @@ export function createGatewayHttpServer(opts: {
canvasHost, canvasHost,
controlUiEnabled, controlUiEnabled,
controlUiBasePath, controlUiBasePath,
openAiChatCompletionsEnabled,
handleHooksRequest, handleHooksRequest,
resolvedAuth, resolvedAuth,
} = opts; } = opts;
@@ -222,8 +224,10 @@ export function createGatewayHttpServer(opts: {
void (async () => { void (async () => {
if (await handleHooksRequest(req, res)) return; if (await handleHooksRequest(req, res)) return;
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) if (openAiChatCompletionsEnabled) {
return; if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth }))
return;
}
if (canvasHost) { if (canvasHost) {
if (await handleA2uiHttpRequest(req, res)) return; if (await handleA2uiHttpRequest(req, res)) return;
if (await canvasHost.handleHttpRequest(req, res)) return; if (await canvasHost.handleHttpRequest(req, res)) return;

View File

@@ -328,6 +328,11 @@ export type GatewayServerOptions = {
* Default: config `gateway.controlUi.enabled` (or true when absent). * Default: config `gateway.controlUi.enabled` (or true when absent).
*/ */
controlUiEnabled?: boolean; 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). * Override gateway auth configuration (merges with config).
*/ */
@@ -432,6 +437,10 @@ export async function startGatewayServer(
} }
const controlUiEnabled = const controlUiEnabled =
opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true; opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true;
const openAiChatCompletionsEnabled =
opts.openAiChatCompletionsEnabled ??
cfgAtStart.gateway?.http?.endpoints?.chatCompletions?.enabled ??
true;
const controlUiBasePath = normalizeControlUiBasePath( const controlUiBasePath = normalizeControlUiBasePath(
cfgAtStart.gateway?.controlUi?.basePath, cfgAtStart.gateway?.controlUi?.basePath,
); );
@@ -615,6 +624,7 @@ export async function startGatewayServer(
canvasHost, canvasHost,
controlUiEnabled, controlUiEnabled,
controlUiBasePath, controlUiBasePath,
openAiChatCompletionsEnabled,
handleHooksRequest, handleHooksRequest,
resolvedAuth, resolvedAuth,
}); });