DeepInfra and Parasail validate tool_choice but do not enforce it

high · inference · probed live 2026-07-26 (DeepInfra) · 2026-08-02 (Parasail) · affects DeepInfra and Parasail

Both providers parse and validate the tool_choice field — send an invalid value or force a tool that isn't in tools and you get a 4xx error. Every surface signal says the capability exists. But a valid constraint has zero effect on what the model generates. Validation without enforcement is worse than ignoring the field outright: nothing warns you the guarantee is absent.

What was measured on DeepInfra

tool_choice: "required" on a prose-inviting prompt (download):

tool-choice-required.deepinfra.json
{
  "model": "google/gemma-4-31B-it",
  "messages": [
    {
      "role": "user",
      "content": "Just say hello."
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "log_event",
        "description": "Logs an event message.",
        "parameters": {
          "type": "object",
          "properties": {
            "message": {
              "type": "string"
            }
          }
        }
      }
    }
  ],
  "tool_choice": "required",
  "chat_template_kwargs": {
    "enable_thinking": false
  },
  "temperature": 0,
  "max_tokens": 200
}

Received — HTTP 200, prose, zero tool calls — and a self-contradicting envelope:

response · HTTP 200, choices[0]
{
  "index": 0,
  "message": {
    "role": "assistant",
    "content": "Hello!",
    "reasoning_content": null,
    "name": null,
    "tool_calls": null
  },
  "finish_reason": "tool_calls",
  "logprobs": null
}

finish_reason: "tool_calls" with tool_calls: null: the constraint reached the layer that stamps the finish reason, and never reached decoding.

Forcing get_time by name on a weather prompt, with get_weather, get_news and get_time offered (download):

tool-choice-specific.deepinfra.json
{
  "model": "google/gemma-4-31B-it",
  "messages": [
    {
      "role": "user",
      "content": "What's the weather in Paris right now?"
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "Current weather for a city.",
        "parameters": {
          "type": "object",
          "properties": {
            "city": {
              "type": "string"
            }
          }
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "get_news",
        "description": "Latest news on a topic.",
        "parameters": {
          "type": "object",
          "properties": {
            "topic": {
              "type": "string"
            }
          }
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "get_time",
        "description": "Current time in a timezone.",
        "parameters": {
          "type": "object",
          "properties": {
            "timezone": {
              "type": "string"
            }
          }
        }
      }
    }
  ],
  "tool_choice": {
    "type": "function",
    "function": {
      "name": "get_time"
    }
  },
  "chat_template_kwargs": {
    "enable_thinking": false
  },
  "temperature": 0,
  "max_tokens": 200
}

Received — HTTP 200, one call, to the wrong tool:

response · HTTP 200, choices[0]
{
  "index": 0,
  "message": {
    "role": "assistant",
    "content": "",
    "reasoning_content": null,
    "name": null,
    "tool_calls": [
      {
        "id": "chatcmpl-tool-a52fe694592aca60",
        "type": "function",
        "function": {
          "name": "get_weather",
          "arguments": "{\"city\": \"Paris\"}"
        }
      }
    ]
  },
  "finish_reason": "tool_calls",
  "logprobs": null
}

get_time was forced; get_weather — the obvious tool for the prompt — came back. That is the signature of the constraint being dropped, not misread.

What was measured on Parasail

The same signature. Parasail enforced all three tool_choice conditions when probed on 2026-07-27; since 2026-08-01 it ignores "required" and forced-specific constraints, stable across three runs through 2026-08-02.

tool_choice: "required" on the same prose-inviting prompt (download):

tool-choice-required.parasail.json
{
  "model": "google/gemma-4-31B-it",
  "messages": [
    {
      "role": "user",
      "content": "Just say hello."
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "log_event",
        "description": "Logs an event message.",
        "parameters": {
          "type": "object",
          "properties": {
            "message": {
              "type": "string"
            }
          }
        }
      }
    }
  ],
  "tool_choice": "required",
  "chat_template_kwargs": {
    "enable_thinking": false
  },
  "temperature": 0,
  "max_tokens": 200
}

Received — HTTP 200, prose, zero tool calls, and the same self-contradicting envelope: finish_reason: "tool_calls" over a message that contains no tool call at all:

response · HTTP 200, choices[0]
{
  "index": 0,
  "message": {
    "role": "assistant",
    "content": "Hello!",
    "reasoning": null
  },
  "finish_reason": "tool_calls",
  "logprobs": null
}

Forcing get_time by name on the weather prompt (download):

tool-choice-specific.parasail.json
{
  "model": "google/gemma-4-31B-it",
  "messages": [
    {
      "role": "user",
      "content": "What's the weather in Paris right now?"
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "Current weather for a city.",
        "parameters": {
          "type": "object",
          "properties": {
            "city": {
              "type": "string"
            }
          }
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "get_news",
        "description": "Latest news on a topic.",
        "parameters": {
          "type": "object",
          "properties": {
            "topic": {
              "type": "string"
            }
          }
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "get_time",
        "description": "Current time in a timezone.",
        "parameters": {
          "type": "object",
          "properties": {
            "timezone": {
              "type": "string"
            }
          }
        }
      }
    }
  ],
  "tool_choice": {
    "type": "function",
    "function": {
      "name": "get_time"
    }
  },
  "chat_template_kwargs": {
    "enable_thinking": false
  },
  "temperature": 0,
  "max_tokens": 200
}

Received — HTTP 200, one call, to the wrong tool — here even the finish reason gives up the pretense and reads "stop":

response · HTTP 200, choices[0]
{
  "index": 0,
  "message": {
    "role": "assistant",
    "content": "",
    "tool_calls": [
      {
        "id": "chatcmpl-tool-90e0ba0e0470cacf",
        "type": "function",
        "function": {
          "name": "get_weather",
          "arguments": "{\"city\": \"Paris\"}"
        }
      }
    ],
    "reasoning": null
  },
  "finish_reason": "stop",
  "logprobs": null
}

And the validation half: tool_choice: "banana" → 400 "Invalid value for tool_choice… Only named tools, "none", "auto" or "required" are supported"; forcing a name not in tools → 400 "The tool specified in tool_choice does not match any of the specified tools". The field is read and checked; the constraint still never reaches decoding.

How this breaks the contract

"required" obliges at least one tool call; a named tool_choice obliges a call to that exact function. Both providers return 200 on both and satisfy neither — and because the field is validated at the surface, every cheap client-side check (no 4xx, plausible finish_reason, well-formed JSON) reports success.

Why it matters

Structured extraction and guaranteed tool loops built on forced calls silently get prose, or a call to a different tool with different semantics. The failure only shows up when downstream code tries to use a result that never arrived. The Parasail history sharpens the risk: an integration validated against enforcing behavior can lose the guarantee later with no signal at the API surface.

Run it yourself

curl · DeepInfra
curl "https://api.deepinfra.com/v1/openai/chat/completions" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d @tool-choice-required.deepinfra.json
curl · Parasail
curl "https://api.parasail.io/v1/chat/completions" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d @tool-choice-required.parasail.json

Three checks on either provider: (1) "required" on "Just say hello." — watch prose come back under finish_reason: "tool_calls"; (2) forced get_time — watch get_weather return; (3) the validation half: change tool_choice to "banana", or force a name not in tools — a 4xx either way (DeepInfra answers 400 and 422, Parasail 400 for both). That last pair proves the field is read, which makes the non-enforcement a choice, not a parsing gap.