Skip to content

OpenAI/Azure tool calling: strict is emitted inside parameters instead of at the function level, so strict mode is a silent no-op for tools #6536

Description

@peax-zim

Searched existing issues/PRs first. The strict items I found all concern the response_format / structured-output path (#3928, #4045, PR #3931, open #4422 "Generated JSON schema fails strict mode checks of Azure", commit a1b7d5e). I could not find any issue/PR about tool/function strict. This report is specifically about the tool-calling path.

Bug description

When building OpenAI tool/function definitions, strict is written inside the parameters JSON-Schema object (via FunctionParameters.Builder.putAdditionalProperty("strict", true)) and the function-level FunctionDefinition.Builder.strict(...) is never called.

Per the OpenAI/Azure API, strict is a field of the function object — a sibling of parameters, alongside name/description — not a JSON-Schema keyword. Placed inside parameters, strict is an unrecognized schema keyword and is ignored by the provider. The net effect is that strict mode is a silent no-op for tool calling: the framework hardcodes strict = true, but the model runs best-effort tool-argument generation with no schema enforcement. No error is raised, so the misbehavior is invisible.

This affects both spring-ai-openai (OpenAiChatModel) and spring-ai-openai-sdk (OpenAiSdkChatModel).

Relevant code (OpenAiChatModel#getChatCompletionTools, main):

parametersBuilder.putAdditionalProperty("strict", JsonValue.from(true)); // TODO allow non-strict
...
FunctionDefinition functionDefinition = FunctionDefinition.builder()
    .name(toolDefinition.name())
    .description(toolDefinition.description())
    .parameters(parametersBuilder.build())   // strict ends up INSIDE parameters
    .build();                                 // FunctionDefinition.Builder.strict(...) never called

The OpenAI Java SDK already exposes FunctionDefinition.Builder.strict(Boolean) — it is simply not used here.

Environment

  • Spring AI: 2.0.0 — and the defect is still present on main.
  • Affected module(s): spring-ai-openai (OpenAiChatModel#getChatCompletionTools); the same defect is in spring-ai-openai-sdk (OpenAiSdkChatModel).
  • Provider: reproducible on both OpenAI and Azure OpenAI — the misplacement is provider-agnostic.

(Java/Spring Boot versions and vector store are not relevant here: this is a deterministic tool-schema serialization defect, independent of runtime or retrieval configuration.)

Steps to reproduce

  1. Register any @Tool/ToolCallback and call OpenAiChatModel (or OpenAiSdkChatModel) with a prompt that triggers the tool.
  2. Capture the outgoing /chat/completions request body.
  3. Inspect tools[0].function.

Observed request (abbreviated):

{
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "Get weather",
        "parameters": {
          "type": "object",
          "properties": { "location": { "type": "string" } },
          "strict": true            // ← placed here; ignored by the API
        }
      }
    }
  ]
}

There is no tools[0].function.strict. Because strict sits inside the JSON schema, the provider ignores it and the request is treated as non-strict. (This also explains why non-strict-compliant tool schemas — e.g. objects without additionalProperties:false/required — are accepted rather than rejected the way the response_format path rejects them in #4422.)

Expected behavior

strict should be set at the function level and omitted from the JSON schema:

{
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "Get weather",
        "strict": true,             // ← function-level, honored by the API
        "parameters": {
          "type": "object",
          "properties": { "location": { "type": "string" } },
          "additionalProperties": false,
          "required": ["location"]
        }
      }
    }
  ]
}

i.e. call FunctionDefinition.builder()....parameters(...).strict(true).build() instead of injecting "strict" into the parameters object. Ideally the value is configurable (per the existing // TODO allow non-strict) via OpenAiChatOptions, since strict imposes schema requirements (additionalProperties:false and all fields required at every level) that not every tool schema satisfies.

Minimal Complete Reproducible example

A test that captures the request body and asserts strict placement (fails on current behavior):

@Test
void toolStrictIsEmittedAtFunctionLevel_notInsideParameters() throws Exception {
  try (var server = new MockWebServer()) {
    server.enqueue(new MockResponse()
        .setHeader("Content-Type", "application/json")
        // minimal valid assistant response, no tool call needed to inspect the request
        .setBody("""
            {"id":"x","object":"chat.completion","created":0,"model":"gpt-4.1",
             "choices":[{"index":0,"finish_reason":"stop",
                         "message":{"role":"assistant","content":"ok"}}]}"""));
    server.start();

    var api = OpenAiApi.builder()
        .baseUrl(server.url("/").toString())
        .apiKey(new SimpleApiKey("test"))
        .build();
    var chatModel = OpenAiChatModel.builder()
        .openAiApi(api)
        .defaultOptions(OpenAiChatOptions.builder().model("gpt-4.1").build())
        .build();

    ToolCallback tool = FunctionToolCallback
        .builder("get_weather", (WeatherRequest req) -> "sunny")
        .description("Get weather")
        .inputType(WeatherRequest.class)
        .build();

    chatModel.call(new Prompt("What is the weather?",
        ToolCallingChatOptions.builder().toolCallbacks(tool).build()));

    var recorded = server.takeRequest();
    var body = new ObjectMapper().readTree(recorded.getBody().readUtf8());
    var function = body.at("/tools/0/function");

    // Expected: strict at function level
    assertThat(function.path("strict").asBoolean(false)).isTrue();          // FAILS today
    // And NOT inside the parameters schema
    assertThat(function.path("parameters").has("strict")).isFalse();        // FAILS today
  }
}

record WeatherRequest(String location) {}

(Builder API names may need minor adjustment to the exact release; the assertion on tools[0].function.strict vs tools[0].function.parameters.strict is the point.)

Suggested fix

In OpenAiChatModel#getChatCompletionTools (and the OpenAiSdkChatModel equivalent): drop parametersBuilder.putAdditionalProperty("strict", ...) and instead call FunctionDefinition.Builder.strict(<configurable>). Expose the flag on OpenAiChatOptions (default configurable) to resolve the existing // TODO allow non-strict.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions