# 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 ·
HTML version: [https://inferencecanary.com/tool-choice-unenforced](https://inferencecanary.com/tool-choice-unenforced)

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
([https://inferencecanary.com/assets/repro/tool-choice-required.deepinfra.json](https://inferencecanary.com/assets/repro/tool-choice-required.deepinfra.json)):

```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:

```json
{
  "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
([https://inferencecanary.com/assets/repro/tool-choice-specific.deepinfra.json](https://inferencecanary.com/assets/repro/tool-choice-specific.deepinfra.json)):

```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:

```json
{
  "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
([https://inferencecanary.com/assets/repro/tool-choice-required.parasail.json](https://inferencecanary.com/assets/repro/tool-choice-required.parasail.json)):

```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:

```json
{
  "index": 0,
  "message": {
    "role": "assistant",
    "content": "Hello!",
    "reasoning": null
  },
  "finish_reason": "tool_calls",
  "logprobs": null
}
```

Forcing `get_time` by name on the weather prompt
([https://inferencecanary.com/assets/repro/tool-choice-specific.parasail.json](https://inferencecanary.com/assets/repro/tool-choice-specific.parasail.json)):

```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"`:

```json
{
  "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 "https://api.deepinfra.com/v1/openai/chat/completions" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d @tool-choice-required.deepinfra.json
```

```
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.
