diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index bb4aef2163fa..8d41060a5829 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -24,6 +24,8 @@ const mocks = vi.hoisted(() => { }; const llama = { loadModel: vi.fn(async () => model), + createGrammarForJsonSchema: vi.fn(async (schema: unknown) => ({ schema })), + getGrammarFor: vi.fn(async (type: string) => ({ type })), dispose: llamaDispose, }; return { @@ -235,6 +237,96 @@ describe("llama.cpp inference provider", () => { }); }); + it("builds a JSON Schema grammar for tool-free responseFormat requests", async () => { + const schema = { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }; + const stream = await createLlamaCppStreamFn({})( + model, + { messages: [{ role: "user", content: "Hi", timestamp: 1 }] }, + { responseFormat: schema }, + ); + + await collectEvents(stream); + + expect(mocks.llama.createGrammarForJsonSchema).toHaveBeenCalledWith(schema); + expect(mocks.generateResponse.mock.calls[0]?.[1]).toMatchObject({ + grammar: { schema }, + }); + expect(mocks.generateResponse.mock.calls[0]?.[1]).not.toHaveProperty("functions"); + }); + + it("unwraps provider-shaped json_schema response formats", async () => { + const schema = { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }; + const stream = await createLlamaCppStreamFn({})( + model, + { messages: [{ role: "user", content: "Hi", timestamp: 1 }] }, + { + responseFormat: { + type: "json_schema", + json_schema: { name: "planner", schema }, + }, + }, + ); + + await collectEvents(stream); + + expect(mocks.llama.createGrammarForJsonSchema).toHaveBeenCalledWith(schema); + expect(mocks.generateResponse.mock.calls[0]?.[1]).toMatchObject({ grammar: { schema } }); + }); + + it("maps provider-shaped json_object response formats to the JSON grammar", async () => { + const stream = await createLlamaCppStreamFn({})( + model, + { messages: [{ role: "user", content: "Hi", timestamp: 1 }] }, + { responseFormat: { type: "json_object" } }, + ); + + await collectEvents(stream); + + expect(mocks.llama.getGrammarFor).toHaveBeenCalledWith("json"); + expect(mocks.generateResponse.mock.calls[0]?.[1]).toMatchObject({ + grammar: { type: "json" }, + }); + }); + + it("maps an empty JSON Schema to the generic JSON grammar", async () => { + const stream = await createLlamaCppStreamFn({})( + model, + { messages: [{ role: "user", content: "Hi", timestamp: 1 }] }, + { responseFormat: {} }, + ); + + await collectEvents(stream); + + expect(mocks.llama.getGrammarFor).toHaveBeenCalledWith("json"); + expect(mocks.generateResponse.mock.calls[0]?.[1]).toMatchObject({ + grammar: { type: "json" }, + }); + }); + + it("keeps provider-shaped text response formats unconstrained", async () => { + const stream = await createLlamaCppStreamFn({})( + model, + { messages: [{ role: "user", content: "Hi", timestamp: 1 }] }, + { responseFormat: { type: "text" } }, + ); + + await collectEvents(stream); + + expect(mocks.llama.getGrammarFor).not.toHaveBeenCalled(); + expect(mocks.llama.createGrammarForJsonSchema).not.toHaveBeenCalled(); + expect(mocks.generateResponse.mock.calls[0]?.[1]).not.toHaveProperty("grammar"); + }); + it("emits native function calls in the final assistant message", async () => { mocks.generateResponse.mockResolvedValueOnce({ response: "", @@ -269,6 +361,40 @@ describe("llama.cpp inference provider", () => { ], }, }); + expect(mocks.llama.createGrammarForJsonSchema).not.toHaveBeenCalled(); + }); + + it("lets tools win when responseFormat is also present", async () => { + const stream = await createLlamaCppStreamFn({})( + model, + { + messages: [{ role: "user", content: "Weather?", timestamp: 1 }], + tools: [ + { + name: "weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + ], + }, + { + responseFormat: { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }, + }, + ); + + await collectEvents(stream); + + expect(mocks.llama.createGrammarForJsonSchema).not.toHaveBeenCalled(); + expect(mocks.generateResponse.mock.calls[0]?.[1]).toMatchObject({ + functions: { weather: expect.any(Object) }, + documentFunctionParams: true, + }); + expect(mocks.generateResponse.mock.calls[0]?.[1]).not.toHaveProperty("grammar"); }); it("disposes the previous model and context when the model changes", async () => { diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index 97ea75d667a1..e163f645c94a 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -30,11 +30,14 @@ import { type LoadedModel = { key: string; + llama: Llama; model: LlamaModel; context: LlamaContext; sequence: LlamaContextSequence; }; +type LlamaJsonSchemaInput = Parameters[0]; + // Process-owned, single-slot cache. A model/context pair lives until another // model replaces it or the process exits, bounding resident model memory. let loadedModel: LoadedModel | undefined; @@ -94,6 +97,33 @@ function normalizeArguments(value: unknown): Record { : {}; } +async function resolveLlamaCppResponseGrammar(params: { + llama: Llama; + responseFormat: Record | undefined; +}) { + const responseFormat = params.responseFormat; + if (!responseFormat) { + return undefined; + } + if (Object.keys(responseFormat).length === 0) { + return await params.llama.getGrammarFor("json"); + } + if (responseFormat.type === "json_object") { + return await params.llama.getGrammarFor("json"); + } + if (responseFormat.type === "text") { + return undefined; + } + if (responseFormat.type === "json_schema") { + const envelope = normalizeArguments(responseFormat.json_schema); + const schema = normalizeArguments(envelope.schema); + return Object.keys(schema).length > 0 + ? await params.llama.createGrammarForJsonSchema(schema as LlamaJsonSchemaInput) + : await params.llama.getGrammarFor("json"); + } + return await params.llama.createGrammarForJsonSchema(responseFormat as LlamaJsonSchemaInput); +} + function mapContextToLlamaChatHistory(context: Context): ChatHistoryItem[] { const history: ChatHistoryItem[] = []; if (context.systemPrompt?.trim()) { @@ -237,7 +267,7 @@ async function getLoadedModel(params: { // Serialized requests reuse this one sequence. Disposing/reallocating it per // turn races node-llama-cpp's asynchronous sequence-id reclamation. const sequence = context.getSequence(); - loadedModel = { key, model, context, sequence }; + loadedModel = { key, llama, model, context, sequence }; return loadedModel; } catch (error) { await context?.dispose(); @@ -312,6 +342,7 @@ export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderC autoDisposeSequence: false, }); const before = sequence.tokenMeter.getState(); + const functions = mapToolsToLlamaFunctions(context); let textStarted = false; const partial = () => buildMessage({ @@ -332,15 +363,31 @@ export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderC stream.push({ type: "text_delta", contentIndex: 0, delta }); }; try { - const result = await chat.generateResponse(mapContextToLlamaChatHistory(context), { - functions: mapToolsToLlamaFunctions(context), - documentFunctionParams: true, + // node-llama-cpp makes grammar and functions mutually exclusive. Tool + // turns keep function calling; constrained decoding is for tool-free turns. + const grammar = + functions || !options?.responseFormat + ? undefined + : await resolveLlamaCppResponseGrammar({ + llama: loaded.llama, + responseFormat: options.responseFormat, + }); + const generationOptions = { signal: options?.signal, maxTokens: options?.maxTokens ?? model.maxTokens, temperature: options?.temperature, customStopTriggers: options?.stop, onTextChunk: appendTextDelta, - }); + ...(functions + ? { functions, documentFunctionParams: true as const } + : grammar + ? { grammar } + : {}), + }; + const result = await chat.generateResponse( + mapContextToLlamaChatHistory(context), + generationOptions, + ); if (result.metadata.stopReason === "abort" || signal?.aborted) { generationAborted = true; throw signal?.reason ?? new Error("Request was aborted"); diff --git a/extensions/ollama/src/provider-models.test.ts b/extensions/ollama/src/provider-models.test.ts index be6e9667ccc3..444febab3599 100644 --- a/extensions/ollama/src/provider-models.test.ts +++ b/extensions/ollama/src/provider-models.test.ts @@ -379,6 +379,7 @@ describe("ollama provider models", () => { expect(visionModel.reasoning).toBe(true); expect(visionModel.compat?.supportsTools).toBe(true); expect(visionModel.compat?.supportsUsageInStreaming).toBe(true); + expect(visionModel.compat?.supportsJsonSchemaResponseFormat).toBe(false); const textModel = buildOllamaModelDefinition("glm-5.1:cloud", 202752, ["completion", "tools"]); expect(textModel.input).toEqual(["text"]); @@ -403,6 +404,7 @@ describe("ollama provider models", () => { expect(noCapabilities.input).toEqual(["text"]); expect(noCapabilities.compat?.supportsTools).toBe(true); expect(noCapabilities.compat?.supportsUsageInStreaming).toBe(true); + expect(noCapabilities.compat?.supportsJsonSchemaResponseFormat).toBe(true); }); it("disables tool support when Ollama capabilities omit tools", () => { diff --git a/extensions/ollama/src/provider-models.ts b/extensions/ollama/src/provider-models.ts index 07e652824192..438f0a4403eb 100644 --- a/extensions/ollama/src/provider-models.ts +++ b/extensions/ollama/src/provider-models.ts @@ -313,10 +313,15 @@ export function buildOllamaModelDefinition( : capabilities.includes("thinking")); const compat = capabilities === undefined - ? { supportsTools: true, supportsUsageInStreaming: true } + ? { + supportsTools: true, + supportsUsageInStreaming: true, + supportsJsonSchemaResponseFormat: !isOllamaCloudModel(modelId), + } : { supportsTools: capabilities.includes("tools"), supportsUsageInStreaming: true, + supportsJsonSchemaResponseFormat: !isOllamaCloudModel(modelId), }; return { id: modelId, diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 985929bf1409..343bf7e7f350 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -1830,6 +1830,7 @@ async function createOllamaTestStream(params: { baseUrl: string; defaultHeaders?: Record; model?: Record; + context?: Record; options?: { apiKey?: string; maxTokens?: number; @@ -1837,6 +1838,7 @@ async function createOllamaTestStream(params: { signal?: AbortSignal; timeoutMs?: number; headers?: Record; + responseFormat?: Record; }; }) { const streamFn = createOllamaStreamFn(params.baseUrl, params.defaultHeaders); @@ -1848,9 +1850,9 @@ async function createOllamaTestStream(params: { contextWindow: 131072, ...params.model, } as unknown as Parameters[0], - { + (params.context ?? { messages: [{ role: "user", content: "hello" }], - } as unknown as Parameters[1], + }) as unknown as Parameters[1], (params.options ?? {}) as unknown as Parameters[2], ); } @@ -2591,6 +2593,145 @@ describe("createOllamaStreamFn", () => { ); }); + it("maps responseFormat JSON Schema to native Ollama format", async () => { + await withMockNdjsonFetch( + ['{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":true}'], + async (fetchMock) => { + const schema = { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }; + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + options: { responseFormat: schema }, + }); + + await collectStreamEvents(stream); + + const requestInit = getGuardedFetchCall(fetchMock).init ?? {}; + if (typeof requestInit.body !== "string") { + throw new Error("Expected string request body"); + } + expect(JSON.parse(requestInit.body)).toMatchObject({ format: schema }); + }, + ); + }); + + it("omits native Ollama format when responseFormat is absent", async () => { + await withMockNdjsonFetch( + ['{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":true}'], + async (fetchMock) => { + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + }); + + await collectStreamEvents(stream); + + const requestInit = getGuardedFetchCall(fetchMock).init ?? {}; + if (typeof requestInit.body !== "string") { + throw new Error("Expected string request body"); + } + expect(JSON.parse(requestInit.body)).not.toHaveProperty("format"); + }, + ); + }); + + it("keeps provider-shaped text response formats off the native Ollama wire", async () => { + await withMockNdjsonFetch( + ['{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":true}'], + async (fetchMock) => { + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + options: { responseFormat: { type: "text" } }, + }); + + await collectStreamEvents(stream); + + const requestInit = getGuardedFetchCall(fetchMock).init ?? {}; + if (typeof requestInit.body !== "string") { + throw new Error("Expected string request body"); + } + expect(JSON.parse(requestInit.body)).not.toHaveProperty("format"); + }, + ); + }); + + it.each([ + { + name: "cloud model through a local daemon", + baseUrl: "http://ollama-host:11434", + id: "gemma4:cloud", + }, + { name: "hosted Ollama Cloud", baseUrl: "https://ollama.com/v1", id: "gemma4" }, + ])("omits native Ollama format for $name", async ({ baseUrl, id }) => { + await withMockNdjsonFetch( + ['{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":true}'], + async (fetchMock) => { + const stream = await createOllamaTestStream({ + baseUrl, + model: { id }, + options: { + responseFormat: { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }, + }, + }); + + await collectStreamEvents(stream); + + const requestInit = getGuardedFetchCall(fetchMock).init ?? {}; + if (typeof requestInit.body !== "string") { + throw new Error("Expected string request body"); + } + expect(JSON.parse(requestInit.body)).not.toHaveProperty("format"); + }, + ); + }); + + it("lets native Ollama tools win over responseFormat", async () => { + await withMockNdjsonFetch( + ['{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":true}'], + async (fetchMock) => { + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + context: { + messages: [{ role: "user", content: "weather" }], + tools: [ + { + name: "weather", + description: "Get weather", + parameters: { type: "object", properties: {} }, + }, + ], + }, + options: { + responseFormat: { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }, + }, + }); + + await collectStreamEvents(stream); + + const requestInit = getGuardedFetchCall(fetchMock).init ?? {}; + if (typeof requestInit.body !== "string") { + throw new Error("Expected string request body"); + } + const body = JSON.parse(requestInit.body); + expect(body.tools).toHaveLength(1); + expect(body).not.toHaveProperty("format"); + }, + ); + }); + it("uses configured params.num_ctx for native Ollama chat options", async () => { await withMockNdjsonFetch( [ diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 12a716cbec64..16d345b115b4 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -37,14 +37,14 @@ import { readStringValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; +import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js"; import { normalizeOllamaWireModelId } from "./model-id.js"; import { parseJsonObjectPreservingUnsafeIntegers, parseJsonPreservingUnsafeIntegers, } from "./ollama-json.js"; -import { buildOllamaBaseUrlSsrFPolicy } from "./provider-models.js"; +import { buildOllamaBaseUrlSsrFPolicy, isOllamaCloudModel } from "./provider-models.js"; import { createOllamaVisibleContentSanitizer, sanitizeOllamaFinalVisibleContent, @@ -520,6 +520,38 @@ export function buildOllamaChatRequest(params: { }; } +function resolveOllamaResponseFormat( + responseFormat: Record | undefined, + params: { baseUrl: string; modelId: string }, +): "json" | Record | undefined { + if ( + !responseFormat || + isOllamaCloudModel(params.modelId) || + isOllamaCloudBaseUrl(params.baseUrl) + ) { + return undefined; + } + if (responseFormat.type === "json_object") { + return "json"; + } + if (responseFormat.type === "text") { + return undefined; + } + if (responseFormat.type === "json_schema" && isRecord(responseFormat.json_schema)) { + const schema = responseFormat.json_schema.schema; + return isRecord(schema) ? schema : undefined; + } + return responseFormat; +} + +function isOllamaCloudBaseUrl(baseUrl: string): boolean { + try { + return new URL(baseUrl).origin === OLLAMA_CLOUD_BASE_URL; + } catch { + return false; + } +} + type StreamModelDescriptor = { api: string; provider: string; @@ -603,6 +635,7 @@ interface OllamaChatRequest { tools?: OllamaTool[]; options?: Record; think?: OllamaThinkValue; + format?: "json" | Record; } interface OllamaChatMessage { @@ -1156,6 +1189,20 @@ function createRawOllamaStreamFn( } normalizeOllamaGreedySamplingOptions(ollamaOptions); + // Structured-output grammars constrain the same token stream as tool + // calls. Keep tool-enabled turns capable by letting tools win. + const responseFormat = + ollamaTools.length > 0 + ? undefined + : resolveOllamaResponseFormat(options?.responseFormat, { + baseUrl, + modelId: model.id, + }); + const requestParams = { + ...resolveOllamaTopLevelParams(model), + ...(responseFormat !== undefined ? { format: responseFormat } : {}), + }; + const body = buildOllamaChatRequest({ modelId: model.id, providerId: model.provider, @@ -1163,7 +1210,7 @@ function createRawOllamaStreamFn( stream: true, tools: ollamaTools, options: ollamaOptions, - requestParams: resolveOllamaTopLevelParams(model), + requestParams, }); options?.onPayload?.(body, model); const headers: Record = { diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts index 16d52ab86bea..d94854ebaee0 100644 --- a/packages/ai/src/providers/openai-completions-compat.ts +++ b/packages/ai/src/providers/openai-completions-compat.ts @@ -1,4 +1,5 @@ import type { Model, OpenAICompletionsCompat } from "../types.js"; +import { isKnownOpenAIJsonSchemaModelId } from "./openai-response-format.js"; type OpenAICompletionsSessionAffinity = "none" | "openai" | "openrouter"; @@ -39,6 +40,7 @@ const DEFAULT_OPENAI_COMPLETIONS_COMPAT = { vercelGatewayRouting: {}, zaiToolStream: false, supportsStrictMode: true, + supportsJsonSchemaResponseFormat: false, cacheControlFormat: undefined, sessionAffinityFormat: "openai", supportsPromptCacheKey: false, @@ -297,6 +299,11 @@ export function resolveOpenAICompletionsCompat( vercelGatewayRouting: configured?.vercelGatewayRouting ?? detected.vercelGatewayRouting, zaiToolStream: configured?.zaiToolStream ?? detected.zaiToolStream, supportsStrictMode: configured?.supportsStrictMode ?? detected.supportsStrictMode, + supportsJsonSchemaResponseFormat: + configured?.supportsJsonSchemaResponseFormat ?? + (model.provider === "openai" && + model.baseUrl.includes("api.openai.com") && + isKnownOpenAIJsonSchemaModelId(model.id)), cacheControlFormat: configured?.cacheControlFormat ?? detected.cacheControlFormat, sessionAffinity: resolveSessionAffinity(model, detected.sessionAffinityFormat), supportsPromptCacheKey: configured?.supportsPromptCacheKey ?? detected.supportsPromptCacheKey, diff --git a/packages/ai/src/providers/openai-completions.compat.test.ts b/packages/ai/src/providers/openai-completions.compat.test.ts index 4f0b7799a0ff..b971d45bf094 100644 --- a/packages/ai/src/providers/openai-completions.compat.test.ts +++ b/packages/ai/src/providers/openai-completions.compat.test.ts @@ -87,6 +87,7 @@ const defaultResolvedCompat = { vercelGatewayRouting: {}, zaiToolStream: false, supportsStrictMode: true, + supportsJsonSchemaResponseFormat: false, cacheControlFormat: undefined, sessionAffinity: "none", supportsPromptCacheKey: false, @@ -109,6 +110,62 @@ beforeEach(() => { }); describe("OpenAI-compatible completions compatibility", () => { + it("lets Ollama tools win over JSON Schema response formats", async () => { + const model = createModel({ + id: "gemma4:e4b", + provider: "ollama", + baseUrl: "http://127.0.0.1:11434/v1", + compat: { supportsJsonSchemaResponseFormat: true }, + }); + + await streamOpenAICompletions( + model, + { + messages: [userMessage], + tools: [ + { + name: "weather", + description: "Get weather", + parameters: { type: "object", properties: {} }, + }, + ], + }, + { + apiKey: "test", + responseFormat: { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }, + }, + ).result(); + + expect(mockOpenAI.payloads[0]).toMatchObject({ tools: [expect.any(Object)] }); + expect(mockOpenAI.payloads[0]).not.toHaveProperty("response_format"); + }); + + it("omits JSON Schema response formats for hosted Ollama Cloud", async () => { + const model = createModel({ + id: "gemma4", + provider: "ollama", + baseUrl: "https://ollama.com/v1", + compat: { supportsJsonSchemaResponseFormat: true }, + }); + + await streamOpenAICompletions(model, context, { + apiKey: "test", + responseFormat: { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }, + }).result(); + + expect(mockOpenAI.payloads[0]).not.toHaveProperty("response_format"); + }); + it.each([ { name: "OpenRouter Anthropic", @@ -161,7 +218,7 @@ describe("OpenAI-compatible completions compatibility", () => { provider: "openai", baseUrl: "https://api.openai.com/v1", }), - expected: defaultResolvedCompat, + expected: { ...defaultResolvedCompat, supportsJsonSchemaResponseFormat: true }, }, { name: "Azure OpenAI", @@ -172,6 +229,15 @@ describe("OpenAI-compatible completions compatibility", () => { }), expected: defaultResolvedCompat, }, + { + name: "OpenAI legacy model", + model: createModel({ + id: "gpt-4-turbo", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + }), + expected: defaultResolvedCompat, + }, { name: "custom proxy", model: createModel(), diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 156f0b3f0976..05ea2794bfaa 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -63,6 +63,10 @@ import { type ResolvedOpenAICompletionsCompat, } from "./openai-completions-compat.js"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; +import { + resolveOpenAICompletionsResponseFormat, + shouldOmitOllamaCompatResponseFormat, +} from "./openai-response-format.js"; import { mapOpenAIStopReason } from "./openai-stop-reason.js"; import { projectOpenAITools, @@ -727,9 +731,10 @@ function buildParams( type ChatCompletionRequestParams = Omit< OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, - "reasoning_effort" + "reasoning_effort" | "response_format" > & { reasoning_effort?: string; + response_format?: Record; stream_options?: { include_usage: boolean }; max_tokens?: number; prompt_cache_key?: string; @@ -784,6 +789,24 @@ function buildParams( params.stop = options.stop; } + const requestedResponseFormat = options?.responseFormat; + const responseFormat = + requestedResponseFormat === undefined + ? undefined + : resolveOpenAICompletionsResponseFormat( + shouldOmitOllamaCompatResponseFormat({ + provider: model.provider, + baseUrl: model.baseUrl, + hasTools: () => Boolean(context.tools?.length), + }) + ? undefined + : requestedResponseFormat, + compat.supportsJsonSchemaResponseFormat, + ); + if (responseFormat !== undefined) { + params.response_format = responseFormat; + } + let toolProjection: OpenAIToolProjection | undefined; if (context.tools) { const converted = convertTools(context.tools, compat); diff --git a/packages/ai/src/providers/openai-response-format.ts b/packages/ai/src/providers/openai-response-format.ts new file mode 100644 index 000000000000..820ce3f7ac31 --- /dev/null +++ b/packages/ai/src/providers/openai-response-format.ts @@ -0,0 +1,69 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; + +const OPENCLAW_RESPONSE_FORMAT_NAME = "openclaw_response"; +const OLLAMA_CLOUD_ORIGIN = "https://ollama.com"; + +export function isKnownOpenAIJsonSchemaModelId(modelId: string | undefined): boolean { + if (typeof modelId !== "string") { + return false; + } + if (/^gpt-5(?:[.-]|$)/i.test(modelId) || /^gpt-4\.1(?:-|$)/i.test(modelId)) { + return true; + } + const gpt4o = /^gpt-4o(-mini)?(?:-(\d{4}-\d{2}-\d{2}))?$/i.exec(modelId); + if (gpt4o) { + const snapshot = gpt4o[2]; + return !snapshot || snapshot >= (gpt4o[1] ? "2024-07-18" : "2024-08-06"); + } + return /^(?:o1|o3(?:-mini|-pro)?|o4-mini)(?:-\d{4}-\d{2}-\d{2})?$/i.test(modelId); +} + +export function shouldOmitOllamaCompatResponseFormat(params: { + provider: string; + baseUrl: string; + hasTools: () => boolean; +}): boolean { + if (!params.provider.includes("ollama")) { + return false; + } + if (params.hasTools()) { + return true; + } + try { + return new URL(params.baseUrl).origin === OLLAMA_CLOUD_ORIGIN; + } catch { + return false; + } +} + +/** + * Maps the shared JSON Schema option to Chat Completions while preserving the + * older provider-shaped json_object/json_schema inputs accepted by model params. + */ +export function resolveOpenAICompletionsResponseFormat( + responseFormat: Record | undefined, + supportsJsonSchemaResponseFormat: boolean, +): Record | undefined { + if (!responseFormat) { + return undefined; + } + if (responseFormat.type === "json_object") { + return responseFormat; + } + if (responseFormat.type === "text") { + return responseFormat; + } + if (responseFormat.type === "json_schema" && isRecord(responseFormat.json_schema)) { + return responseFormat; + } + if (!supportsJsonSchemaResponseFormat) { + return undefined; + } + return { + type: "json_schema", + json_schema: { + name: OPENCLAW_RESPONSE_FORMAT_NAME, + schema: responseFormat, + }, + }; +} diff --git a/packages/ai/src/transports/openai-completions-compat.ts b/packages/ai/src/transports/openai-completions-compat.ts index 336555a44782..3d47b242bfb4 100644 --- a/packages/ai/src/transports/openai-completions-compat.ts +++ b/packages/ai/src/transports/openai-completions-compat.ts @@ -6,6 +6,7 @@ */ import type { Model } from "@openclaw/llm-core"; import type { AiProviderRequestCapabilities, AiProviderRequestPolicyInput } from "../host.js"; +import { isKnownOpenAIJsonSchemaModelId } from "../providers/openai-response-format.js"; import { resolveProviderRequestCapabilities } from "./host-policy.js"; type ProviderEndpointClass = string; @@ -13,6 +14,7 @@ type ProviderRequestCapabilities = AiProviderRequestCapabilities; type OpenAICompletionsCompatDefaultsInput = { provider?: string; + modelId?: string; endpointClass: ProviderEndpointClass; knownProviderFamily: string; supportsNativeStreamingUsageCompat?: boolean; @@ -29,6 +31,7 @@ type OpenAICompletionsCompatDefaults = { thinkingFormat: "openai" | "openrouter" | "deepseek" | "together" | "zai"; visibleReasoningDetailTypes: string[]; supportsStrictMode: boolean; + supportsJsonSchemaResponseFormat: boolean; requiresReasoningContentOnAssistantMessages: boolean; requiresNonEmptyUserOrAssistantMessage: boolean; }; @@ -48,6 +51,7 @@ function resolveOpenAICompletionsCompatDefaults( ): OpenAICompletionsCompatDefaults { const { provider, + modelId, endpointClass, knownProviderFamily, supportsNativeStreamingUsageCompat = false, @@ -127,6 +131,10 @@ function resolveOpenAICompletionsCompatDefaults( : "openai", visibleReasoningDetailTypes: isOpenRouterLike ? ["response.output_text", "response.text"] : [], supportsStrictMode: !isZai && !usesConfiguredNonOpenAIEndpoint, + supportsJsonSchemaResponseFormat: + (endpointClass === "openai-public" || + (isDefaultRoute && isDefaultRouteProvider(provider, "openai"))) && + isKnownOpenAIJsonSchemaModelId(modelId), requiresReasoningContentOnAssistantMessages: isDeepSeek || isXiaomi, requiresNonEmptyUserOrAssistantMessage: isModelStudioLike, }; @@ -142,6 +150,7 @@ function resolveOpenAICompletionsCompatDefaultsFromCapabilities( | "usesExplicitProxyLikeEndpoint" > & { provider?: string; + modelId?: string; }, ): OpenAICompletionsCompatDefaults { return resolveOpenAICompletionsCompatDefaults(input); @@ -172,6 +181,7 @@ export function detectOpenAICompletionsCompat( capabilities, defaults: resolveOpenAICompletionsCompatDefaultsFromCapabilities({ provider: model.provider, + modelId: model.id, ...capabilities, }), }; diff --git a/packages/ai/src/transports/openai-completions-transport.ts b/packages/ai/src/transports/openai-completions-transport.ts index 2a9652258d7b..7feef2ffaaa3 100644 --- a/packages/ai/src/transports/openai-completions-transport.ts +++ b/packages/ai/src/transports/openai-completions-transport.ts @@ -28,6 +28,10 @@ import { withFirstStreamEventTimeout, } from "../internal/runtime.js"; import { stripSystemPromptCacheBoundary } from "../internal/shared.js"; +import { + resolveOpenAICompletionsResponseFormat, + shouldOmitOllamaCompatResponseFormat, +} from "../providers/openai-response-format.js"; import { clearPendingCommentaryText, rememberPendingCommentaryTags, @@ -1754,8 +1758,18 @@ export function buildOpenAICompletionsParams( if (options?.topP !== undefined) { params.top_p = options.topP; } - if (options?.responseFormat !== undefined) { - params.response_format = options.responseFormat; + const responseFormat = resolveOpenAICompletionsResponseFormat( + shouldOmitOllamaCompatResponseFormat({ + provider: model.provider, + baseUrl: model.baseUrl, + hasTools: () => Boolean(context.tools?.length), + }) + ? undefined + : options?.responseFormat, + compat.supportsJsonSchemaResponseFormat, + ); + if (responseFormat !== undefined) { + params.response_format = responseFormat; } if (options?.frequencyPenalty !== undefined) { params.frequency_penalty = options.frequencyPenalty; diff --git a/packages/ai/src/transports/openai-transport-params.ts b/packages/ai/src/transports/openai-transport-params.ts index 4a55bc512934..6c9e6507ae90 100644 --- a/packages/ai/src/transports/openai-transport-params.ts +++ b/packages/ai/src/transports/openai-transport-params.ts @@ -267,6 +267,7 @@ function detectCompat(model: OpenAIModeModel) { openRouterRouting: {}, vercelGatewayRouting: {}, supportsStrictMode: defaults.supportsStrictMode, + supportsJsonSchemaResponseFormat: defaults.supportsJsonSchemaResponseFormat, requiresReasoningContentOnAssistantMessages: defaults.requiresReasoningContentOnAssistantMessages, requiresNonEmptyUserOrAssistantMessage: defaults.requiresNonEmptyUserOrAssistantMessage, @@ -299,6 +300,8 @@ export function getCompat(model: OpenAIModeModel) { (compat.vercelGatewayRouting as Record | undefined) ?? detected.vercelGatewayRouting, supportsStrictMode: compat.supportsStrictMode ?? detected.supportsStrictMode, + supportsJsonSchemaResponseFormat: + compat.supportsJsonSchemaResponseFormat ?? detected.supportsJsonSchemaResponseFormat, supportsPromptCacheKey: compat.supportsPromptCacheKey === true, supportsLongCacheRetention: compat.supportsLongCacheRetention !== false, requiresStringContent: compat.requiresStringContent ?? false, diff --git a/packages/llm-core/src/types.ts b/packages/llm-core/src/types.ts index 3664959c3c0a..1e12b4a8c1d2 100644 --- a/packages/llm-core/src/types.ts +++ b/packages/llm-core/src/types.ts @@ -67,6 +67,11 @@ export interface ProviderResponse { export interface StreamOptions { temperature?: number; maxTokens?: number; + /** + * Optional JSON Schema for the generated response. Providers that support + * constrained decoding map it to their native request shape; others ignore it. + */ + responseFormat?: Record; /** * Stop sequences forwarded to providers that support them. Providers map this * to their native request field, such as OpenAI `stop` or Anthropic @@ -466,6 +471,8 @@ export interface OpenAICompletionsCompat { zaiToolStream?: boolean; /** Whether the provider supports the `strict` field in tool definitions. Default: true. */ supportsStrictMode?: boolean; + /** Whether the provider supports JSON Schema through `response_format`. Default: false for unknown compatible endpoints. */ + supportsJsonSchemaResponseFormat?: boolean; /** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. */ cacheControlFormat?: "anthropic"; /** Whether to send known session-affinity headers (`session_id`, `x-client-request-id`, `x-session-affinity`) from `options.sessionId` when caching is enabled. Default: false. */ diff --git a/packages/model-catalog-core/src/model-catalog-normalize.test.ts b/packages/model-catalog-core/src/model-catalog-normalize.test.ts index 588fc242aa18..3267c642f268 100644 --- a/packages/model-catalog-core/src/model-catalog-normalize.test.ts +++ b/packages/model-catalog-core/src/model-catalog-normalize.test.ts @@ -71,6 +71,7 @@ describe("model catalog normalization", () => { sendSessionIdHeader: false, supportsEagerToolInputStreaming: false, supportsLongCacheRetention: true, + supportsJsonSchemaResponseFormat: true, requiresReasoningContentOnAssistantMessages: true, supportsStore: "yes", thinkingFormat: "together", @@ -169,6 +170,7 @@ describe("model catalog normalization", () => { sendSessionIdHeader: false, supportsEagerToolInputStreaming: false, supportsLongCacheRetention: true, + supportsJsonSchemaResponseFormat: true, requiresReasoningContentOnAssistantMessages: true, thinkingFormat: "together", }, diff --git a/packages/model-catalog-core/src/model-catalog-normalize.ts b/packages/model-catalog-core/src/model-catalog-normalize.ts index fa97dbbf1db1..a6dbc94a429d 100644 --- a/packages/model-catalog-core/src/model-catalog-normalize.ts +++ b/packages/model-catalog-core/src/model-catalog-normalize.ts @@ -359,6 +359,7 @@ function normalizeModelCatalogCompat(value: unknown): ModelCatalogCompatConfig | "supportsUsageInStreaming", "supportsTools", "supportsStrictMode", + "supportsJsonSchemaResponseFormat", "requiresStringContent", "strictMessageKeys", "requiresToolResultName", diff --git a/packages/model-catalog-core/src/model-catalog-types.ts b/packages/model-catalog-core/src/model-catalog-types.ts index a737b18e3534..741597788a0c 100644 --- a/packages/model-catalog-core/src/model-catalog-types.ts +++ b/packages/model-catalog-core/src/model-catalog-types.ts @@ -45,6 +45,7 @@ export type ModelCatalogCompatConfig = { supportsTemperature?: boolean; supportsUsageInStreaming?: boolean; supportsStrictMode?: boolean; + supportsJsonSchemaResponseFormat?: boolean; maxTokensField?: "max_completion_tokens" | "max_tokens"; requiresToolResultName?: boolean; requiresAssistantAfterToolResult?: boolean; diff --git a/src/agents/embedded-agent-runner/extra-params.sampling.test.ts b/src/agents/embedded-agent-runner/extra-params.sampling.test.ts index 47fbfa681caf..5d7ee85185ce 100644 --- a/src/agents/embedded-agent-runner/extra-params.sampling.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.sampling.test.ts @@ -238,7 +238,7 @@ describe("createStreamFnWithExtraParams sampling overrides", () => { expect(callOptions?.temperature).toBe(0.4); }); - it("lets request responseFormat override configured response_format", () => { + it("threads a run-scoped responseFormat schema ahead of configured response_format", () => { const underlying = vi.fn(() => ({ push: vi.fn(), result: vi.fn(async () => undefined), @@ -248,6 +248,12 @@ describe("createStreamFnWithExtraParams sampling overrides", () => { })) as unknown as StreamFn; const agent: { streamFn?: StreamFn } = { streamFn: underlying }; + const responseFormat = { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }; applyExtraParamsToAgent( agent, { @@ -266,7 +272,7 @@ describe("createStreamFnWithExtraParams sampling overrides", () => { "openai", "gpt-5.4", { - responseFormat: { type: "json_object" }, + responseFormat, }, ); @@ -282,7 +288,7 @@ describe("createStreamFnWithExtraParams sampling overrides", () => { const callOptions = (underlying as unknown as { mock: { calls: unknown[][] } }).mock .calls[0]?.[2] as { responseFormat?: Record } | undefined; - expect(callOptions?.responseFormat).toEqual({ type: "json_object" }); + expect(callOptions?.responseFormat).toEqual(responseFormat); }); it("keeps request-scoped response_format out of prepared extra params cache", () => { diff --git a/src/agents/embedded-agent-runner/extra-params.ts b/src/agents/embedded-agent-runner/extra-params.ts index 54befb9808ca..dbd6a205d7aa 100644 --- a/src/agents/embedded-agent-runner/extra-params.ts +++ b/src/agents/embedded-agent-runner/extra-params.ts @@ -178,7 +178,6 @@ type CacheRetentionStreamOptions = Partial & { cacheRetention?: "none" | "short" | "long"; cachedContent?: string; topP?: number; - responseFormat?: Record; frequencyPenalty?: number; presencePenalty?: number; seed?: number; diff --git a/src/agents/openai-transport-stream.deepseek-and-shaping.test.ts b/src/agents/openai-transport-stream.deepseek-and-shaping.test.ts index d896815f1bf7..7a7c34a72d4b 100644 --- a/src/agents/openai-transport-stream.deepseek-and-shaping.test.ts +++ b/src/agents/openai-transport-stream.deepseek-and-shaping.test.ts @@ -637,6 +637,13 @@ describe("openai transport stream", () => { expect(params.response_format).toEqual({ type: "json_object" }); } + { + const params = buildOpenAICompletionsParams(model, context, { + responseFormat: { type: "text" }, + }); + expect(params.response_format).toEqual({ type: "text" }); + } + { const params = buildOpenAICompletionsParams(model, context, { responseFormat: { type: "json_schema", json_schema: {} }, @@ -644,12 +651,153 @@ describe("openai transport stream", () => { expect(params.response_format).toEqual({ type: "json_schema", json_schema: {} }); } + { + const schema = { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }; + const params = buildOpenAICompletionsParams(model, context, { responseFormat: schema }); + expect(params.response_format).toEqual({ + type: "json_schema", + json_schema: { name: "openclaw_response", schema }, + }); + } + { const params = buildOpenAICompletionsParams(model, context, {}); expect(params).not.toHaveProperty("response_format"); } }); + it("does not infer JSON Schema response formats for legacy first-party OpenAI models", () => { + const responseFormat = { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }; + const build = (id: string) => + buildOpenAICompletionsParams( + makeCompletionsModel({ + id, + name: id, + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + }), + { messages: [{ role: "user", content: "hi", timestamp: 1 }], tools: [] } as never, + { responseFormat }, + ); + + expect(build("gpt-4-turbo")).not.toHaveProperty("response_format"); + expect(build("gpt-4o-audio-preview")).not.toHaveProperty("response_format"); + expect(build("gpt-4o-2024-05-13")).not.toHaveProperty("response_format"); + expect(build("o1-mini")).not.toHaveProperty("response_format"); + for (const id of [ + "gpt-4o", + "gpt-4o-2024-08-06", + "gpt-4o-mini", + "gpt-4o-mini-2024-07-18", + "gpt-4.1", + "o1", + "o1-2024-12-17", + "o3", + "o4-mini", + ]) { + expect(build(id).response_format).toMatchObject({ type: "json_schema" }); + } + }); + + it("omits JSON Schema response_format for compatible backends without support", () => { + const model = makeCompletionsModel({ + id: "custom-model", + name: "Custom model", + provider: "custom-provider", + baseUrl: "https://models.example/v1", + reasoning: false, + compat: { supportsJsonSchemaResponseFormat: false }, + }); + const schema = { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }; + + const params = buildOpenAICompletionsParams( + model, + { messages: [{ role: "user", content: "hi", timestamp: 1 }], tools: [] } as never, + { responseFormat: schema }, + ); + + expect(params).not.toHaveProperty("response_format"); + + const configuredFormat = { + type: "json_schema", + json_schema: { name: "configured", schema }, + }; + const configuredParams = buildOpenAICompletionsParams( + model, + { messages: [{ role: "user", content: "hi", timestamp: 1 }], tools: [] } as never, + { responseFormat: configuredFormat }, + ); + expect(configuredParams.response_format).toBe(configuredFormat); + }); + + it("maps JSON Schema response_format for Ollama OpenAI-compatible routes", () => { + const model = makeCompletionsModel({ + id: "gemma4:e4b", + name: "Gemma 4 E4B", + provider: "ollama", + baseUrl: "http://127.0.0.1:11434/v1", + reasoning: false, + compat: { supportsJsonSchemaResponseFormat: true }, + }); + const schema = { + type: "object", + properties: { reply: { type: "string" } }, + required: ["reply"], + additionalProperties: false, + }; + + const params = buildOpenAICompletionsParams( + model, + { messages: [{ role: "user", content: "hi", timestamp: 1 }], tools: [] } as never, + { responseFormat: schema }, + ); + + expect(params.response_format).toEqual({ + type: "json_schema", + json_schema: { name: "openclaw_response", schema }, + }); + + const toolParams = buildOpenAICompletionsParams( + model, + { + messages: [{ role: "user", content: "weather", timestamp: 1 }], + tools: [ + { + name: "weather", + description: "Get weather", + parameters: { type: "object", properties: {} }, + }, + ], + } as never, + { responseFormat: schema }, + ); + expect(toolParams.tools).toHaveLength(1); + expect(toolParams).not.toHaveProperty("response_format"); + + const hostedCloudParams = buildOpenAICompletionsParams( + { ...model, id: "gemma4", baseUrl: "https://ollama.com/v1" }, + { messages: [{ role: "user", content: "hi", timestamp: 1 }], tools: [] } as never, + { responseFormat: schema }, + ); + expect(hostedCloudParams).not.toHaveProperty("response_format"); + }); + it("does not build OpenRouter reasoning params for Hunter Alpha when reasoning is disabled", () => { const params = buildOpenAICompletionsParams( makeCompletionsModel({ diff --git a/src/agents/sessions/model-registry.ts b/src/agents/sessions/model-registry.ts index 6557da5412b8..e4bc729567d3 100644 --- a/src/agents/sessions/model-registry.ts +++ b/src/agents/sessions/model-registry.ts @@ -129,6 +129,7 @@ const OpenAICompletionsCompatSchema = Type.Object({ openRouterRouting: Type.Optional(OpenRouterRoutingSchema), vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema), supportsStrictMode: Type.Optional(Type.Boolean()), + supportsJsonSchemaResponseFormat: Type.Optional(Type.Boolean()), supportsLongCacheRetention: Type.Optional(Type.Boolean()), }); diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index e778a056ac2b..6c5ddfb8664d 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -1028,6 +1028,7 @@ describe("model compat config schema", () => { compat: { supportsUsageInStreaming: true, supportsStrictMode: false, + supportsJsonSchemaResponseFormat: true, requiresStringContent: true, thinkingFormat, requiresToolResultName: true, diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 5f66a5b21555..defd36f6530d 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -33,6 +33,7 @@ type SupportedOpenAICompatFields = Pick< | "supportsReasoningEffort" | "supportsUsageInStreaming" | "supportsStrictMode" + | "supportsJsonSchemaResponseFormat" | "maxTokensField" | "requiresToolResultName" | "requiresAssistantAfterToolResult" diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index e7cfef97d621..82d07cf42e61 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -211,6 +211,7 @@ const ModelCompatSchema = z supportsUsageInStreaming: z.boolean().optional(), supportsTools: z.boolean().optional(), supportsStrictMode: z.boolean().optional(), + supportsJsonSchemaResponseFormat: z.boolean().optional(), requiresStringContent: z.boolean().optional(), strictMessageKeys: z.boolean().optional(), visibleReasoningDetailTypes: z.array(z.string().min(1)).optional(), diff --git a/src/system-agent/agent-turn.test.ts b/src/system-agent/agent-turn.test.ts index 54f395e2008a..800fdd633411 100644 --- a/src/system-agent/agent-turn.test.ts +++ b/src/system-agent/agent-turn.test.ts @@ -812,6 +812,7 @@ describe("runSystemAgentTurn", () => { expect(runEmbeddedAgent).toHaveBeenCalledOnce(); expect(runCliAgent).not.toHaveBeenCalled(); const call = requireValue(runEmbeddedAgent.mock.calls[0]?.[0], "missing embedded runner call"); + expect(call).not.toHaveProperty("streamParams"); expect(call).toMatchObject({ provider: "openai", model: "gpt-5.4", diff --git a/src/system-agent/assistant.configured.test.ts b/src/system-agent/assistant.configured.test.ts index a3ec607885c6..8dd2262d7e0a 100644 --- a/src/system-agent/assistant.configured.test.ts +++ b/src/system-agent/assistant.configured.test.ts @@ -155,6 +155,17 @@ describe("OpenClaw configured-model planner", () => { authProfileId: "openai:p2", authProfileIdSource: "user", config: binding.execution.runConfig, + streamParams: { + responseFormat: { + type: "object", + properties: { + reply: { type: "string" }, + command: { type: "string" }, + }, + required: ["reply"], + additionalProperties: false, + }, + }, }), ); }); diff --git a/src/system-agent/assistant.ts b/src/system-agent/assistant.ts index 87f442d7bec4..28aba8859ae7 100644 --- a/src/system-agent/assistant.ts +++ b/src/system-agent/assistant.ts @@ -49,6 +49,16 @@ export type SystemAgentConfiguredModelPlannerDeps = SystemAgentVerifiedInference resolveAssistantTimeoutMs?: typeof resolveSystemAgentAssistantTimeoutMs; }; +const SYSTEM_AGENT_PLANNER_RESPONSE_SCHEMA = { + type: "object", + properties: { + reply: { type: "string" }, + command: { type: "string" }, + }, + required: ["reply"], + additionalProperties: false, +} as const; + export async function planSystemAgentCommand(params: { input: string; overview: SystemAgentOverview; @@ -85,6 +95,7 @@ export async function planSystemAgentCommandWithConfiguredModel(params: { runIdPrefix: "openclaw-planner", verifiedInference: params.verifiedInference, deps: params.deps, + responseFormat: SYSTEM_AGENT_PLANNER_RESPONSE_SCHEMA, }); const parsed = parseSystemAgentAssistantPlanText(result?.text); return parsed && result ? { ...parsed, modelLabel: result.modelLabel } : null; @@ -116,6 +127,7 @@ async function runConfiguredSystemAgentText(params: { readonly verifiedInference: SystemAgentVerifiedInferenceBinding; deps?: SystemAgentConfiguredModelPlannerDeps; timeoutMs?: number; + responseFormat?: Record; }): Promise<{ text: string; modelLabel: string } | null> { const route = await requireVerifiedPlannerRoute(params.verifiedInference, params.deps); let expectedAgentHarnessRuntimeArtifact: ReturnType< @@ -156,6 +168,7 @@ async function runConfiguredSystemAgentText(params: { messageProvider: "openclaw", disableTools: true, disableTrajectory: true, + ...(params.responseFormat ? { streamParams: { responseFormat: params.responseFormat } } : {}), ...(route.authProfileId ? { authProfileId: route.authProfileId } : {}), }; const result =