diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 1d270974d..e01f76a8a 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2336,6 +2336,11 @@ Select the model via `agents.defaults.model.primary` (provider/model). } ``` +Tool calling note (OpenAI-compatible servers): +- Some servers only support tool calling via `POST /v1/chat/completions` when the client sends + `tools` + `tool_choice`. If your `openai-completions` model never emits tool calls, set + `compat: { openaiCompletionsTools: true }` on that model entry to force Moltbot to include tools. + ### OpenCode Zen (multi-model proxy) OpenCode Zen is a multi-model gateway with per-model endpoints. Moltbot uses diff --git a/src/agents/pi-embedded-runner.splitsdktools.test.ts b/src/agents/pi-embedded-runner.splitsdktools.test.ts index fc746e7aa..098afd721 100644 --- a/src/agents/pi-embedded-runner.splitsdktools.test.ts +++ b/src/agents/pi-embedded-runner.splitsdktools.test.ts @@ -120,6 +120,7 @@ describe("splitSdkTools", () => { const { builtInTools, customTools } = splitSdkTools({ tools, sandboxEnabled: true, + modelApi: "openai-responses", }); expect(builtInTools).toEqual([]); expect(customTools.map((tool) => tool.name)).toEqual([ @@ -134,6 +135,7 @@ describe("splitSdkTools", () => { const { builtInTools, customTools } = splitSdkTools({ tools, sandboxEnabled: false, + modelApi: "openai-responses", }); expect(builtInTools).toEqual([]); expect(customTools.map((tool) => tool.name)).toEqual([ @@ -144,4 +146,21 @@ describe("splitSdkTools", () => { "browser", ]); }); + + it("can route tools to builtInTools for openai-completions when enabled", () => { + const { builtInTools, customTools } = splitSdkTools({ + tools, + sandboxEnabled: false, + modelApi: "openai-completions", + openaiCompletionsTools: true, + }); + expect(builtInTools.map((tool) => tool.name)).toEqual([ + "read", + "exec", + "edit", + "write", + "browser", + ]); + expect(customTools).toEqual([]); + }); }); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index dc68561c2..18846d44e 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -378,9 +378,16 @@ export async function compactEmbeddedPiSessionDirect( model, }); + const openaiCompletionsTools = + model.api === "openai-completions" && + (model.compat as { openaiCompletionsTools?: unknown } | undefined) + ?.openaiCompletionsTools === true; + const { builtInTools, customTools } = splitSdkTools({ tools, sandboxEnabled: !!sandbox?.enabled, + modelApi: model.api, + openaiCompletionsTools, }); let session: Awaited>["session"]; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 46a53bd8f..8bc38188d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -432,9 +432,16 @@ export async function runEmbeddedAttempt( model: params.model, }); + const openaiCompletionsTools = + params.model.api === "openai-completions" && + (params.model.compat as { openaiCompletionsTools?: unknown } | undefined) + ?.openaiCompletionsTools === true; + const { builtInTools, customTools } = splitSdkTools({ tools, sandboxEnabled: !!sandbox?.enabled, + modelApi: params.model.api, + openaiCompletionsTools, }); // Add client tools (OpenResponses hosted tools) to customTools diff --git a/src/agents/pi-embedded-runner/tool-split.ts b/src/agents/pi-embedded-runner/tool-split.ts index 13e440a20..56c3937e5 100644 --- a/src/agents/pi-embedded-runner/tool-split.ts +++ b/src/agents/pi-embedded-runner/tool-split.ts @@ -2,15 +2,36 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { toToolDefinitions } from "../pi-tool-definition-adapter.js"; -// We always pass tools via `customTools` so our policy filtering, sandbox integration, -// and extended toolset remain consistent across providers. type AnyAgentTool = AgentTool; -export function splitSdkTools(options: { tools: AnyAgentTool[]; sandboxEnabled: boolean }): { +function isOpenAiCompletionsToolsEnabled(options: { + modelApi?: string; + openaiCompletionsTools?: boolean; +}): boolean { + return options.modelApi === "openai-completions" && options.openaiCompletionsTools === true; +} + +export function splitSdkTools(options: { + tools: AnyAgentTool[]; + sandboxEnabled: boolean; + modelApi?: string; + openaiCompletionsTools?: boolean; +}): { builtInTools: AnyAgentTool[]; customTools: ReturnType; } { const { tools } = options; + // Default behavior: route all tools through `customTools` so our policy filtering, + // sandbox integration, and extended toolset remain consistent across providers. + // + // Some OpenAI-compatible servers (notably local vLLM) only support tool calling on + // /v1/chat/completions when tools are passed via the SDK tool path. + if (isOpenAiCompletionsToolsEnabled(options)) { + return { + builtInTools: tools, + customTools: [], + }; + } return { builtInTools: [], customTools: toToolDefinitions(tools), diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 11b6c64cb..99c2c9167 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -11,6 +11,14 @@ export type ModelCompatConfig = { supportsDeveloperRole?: boolean; supportsReasoningEffort?: boolean; maxTokensField?: "max_completion_tokens" | "max_tokens"; + /** + * For some OpenAI-compatible servers (e.g. local vLLM), tool calling is only + * supported via `POST /v1/chat/completions` with `tools` + `tool_choice`. + * + * When enabled for an `openai-completions` model, Moltbot routes the agent's + * toolset through the SDK tool path so tools are included in the request. + */ + openaiCompletionsTools?: boolean; }; export type ModelProviderAuthMode = "api-key" | "aws-sdk" | "oauth" | "token"; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4a8c80bcc..ff19bf63c 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -19,6 +19,7 @@ export const ModelCompatSchema = z maxTokensField: z .union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) .optional(), + openaiCompletionsTools: z.boolean().optional(), }) .strict() .optional();