cloudflare / cloudflare/ai

workers-ai-provider / Workers AI: gpt-oss leaks forced tool calls into message.content as text (empty tool_calls, finish_reason "stop")

Open
#574 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
1.2k
Forks
345
Avg merge
13h 31m
Merged PRs (30d)
1

Description

## Summary

On Workers AI, the **gpt-oss** models (`@cf/openai/gpt-oss-120b`, `@cf/openai/gpt-oss-20b`) sometimes emit a **forced** tool call as **raw JSON text in `message.content`** instead of a structured `tool_calls` entry. When this happens:

- `choices[0].message.content` is a JSON string like `{"name":"","path":"..."}`
- `choices[0].message.tool_calls` is `[]` (empty)
- `choices[0].finish_reason` is `"stop"` (not `"tool_calls"`)

This breaks any OpenAI-compatible consumer (including `workers-ai-provider` + the Vercel AI SDK): a step that was supposed to force a tool call silently produces no executable tool call, and the "answer" is malformed JSON rather than either a tool call or real prose.

This is **adjacent to but distinct from** #560 (which is about the SDK→provider `tool_choice` *mapping*). #560 is fixed on the client by sending the named-function form; this issue is a **serving-side harmony rendering bug** that the client cannot fully fix.

## Affected models

- `@cf/openai/gpt-oss-120b`
- `@cf/openai/gpt-oss-20b`

Other families I tested (Llama 3.1/3.3/4, Qwen QwQ-32B, Qwen3-30B, Gemma-4) do **not** exhibit this — they return structured `tool_calls`.

## When it happens

It is **load-bearing on fit**: when the forced tool is a *reasonable* fit for the conversation, gpt-oss returns a correct structured tool call. The leak reproduces when the forced tool is a **poor fit** for the conversation (the model "wants" to answer in prose but is forced to call a tool).

### Works correctly (well-fitting forced tool)

Request to `POST /accounts/{account}/ai/run/@cf/openai/gpt-oss-120b`:

```json
{
"messages": [{ "role": "user", "content": "What is 123 + 456? Use the calculator tool." }],
"tools": [{ "type": "function", "function": { "name": "calculator", "description": "Add two numbers and return their sum.", "parameters": { "type": "object", "properties": { "a": { "type": "number" }, "b": { "type": "number" } }, "required": ["a", "b"] } } }],
"max_tokens": 2000,
"tool_choice": { "type": "function", "function": { "name": "calculator" } }
}
```

Response (correct — structured tool call, `finish_reason: "tool_calls"`):

```json
{
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{ "id": "chatcmpl-tool-...", "type": "function", "function": { "name": "calculator", "arguments": "{\"a\": 123, \"b\": 456}" } }],
"reasoning_content": "User asks to compute 123 + 456 using calculator tool..."
},
"finish_reason": "tool_calls"
}]
}
```

### Broken (ill-fitting forced tool)

Same endpoint, a prose-tempting prompt forced to call an unrelated tool:

```json
{
"messages": [
{ "role": "system", "content": "You are a warm coach." },
{ "role": "user", "content": "That went really well! How do you think I did?" }
],
"tools": [{ "type": "function", "function": { "name": "read_skill_resource", "description": "Read a bundled resource file.", "parameters": { "type": "object", "properties": { "name": { "type": "string" }, "path": { "type": "string" } }, "required": ["name", "path"] } } }],
"max_tokens": 2000,
"tool_choice": { "type": "function", "function": { "name": "read_skill_resource" } }
}
```

Observed responses across repeated runs (note the empty `tool_calls`, `finish_reason: "stop"`, and the tool call serialized as a JSON string in `content`):

```jsonc
// gpt-oss-120b, run 1
{ "message": { "content": "{ \"name\": \"read_skill_resource\", \"path\": \"coaching_feedback_template.txt\" }", "tool_calls": [] }, "finish_reason": "stop" }

// gpt-oss-120b, run 2 — "name" is a HARMONY CHANNEL, not a tool
{ "message": { "content": "{\"name\":\"analysis\",\"path\":\"No external resources needed.\"}", "tool_calls": [] }, "finish_reason": "stop" }

// gpt-oss-120b, run 3 — "name" is a ROLE
{ "message": { "content": "{\"name\":\"assistant\",\"path\":\"final\"}", "tool_calls": [] }, "finish_reason": "stop" }

// gpt-oss-20b, run 1
{ "message": { "content": "{\"name\":\"read_skill_resource\",\"path\":\"warm_coach_supporting_response.txt\"}", "tool_calls": [] }, "finish_reason": "stop" }

// gpt-oss-20b, run 2 — hallucinated tool name
{ "message": { "content": "{\"name\":\"warm_coach_feedback\",\"path\":\"feedback.txt\"}", "tool_calls": [] }, "finish_reason": "stop" }
```

The same happens when streaming (`"stream": true`): the JSON arrives as `choices[0].delta.content` text deltas (gpt-oss does stream via SSE for this case), with `finish_reason: "stop"` and no `delta.tool_calls`.

## Why this is a serving-side bug

The harmony format routes tool calls through the `commentary` channel and final answers through the `final` channel. In the broken cases the model's `reasoning_content` shows it is reasoning about a *prose* answer, and the would-be tool call lands in the **final channel as a JSON string** instead of being parsed into a structured `tool_calls` entry. So the harmony parser (in the vLLM/serving layer) is not detecting/lifting the tool call when the model is "torn" between answering and calling the forced tool.

The unreliability of the `name` field is the key tell: it is variously the real tool name, a **channel name** (`"analysis"`), a **role** (`"assistant"`), or a **hallucination**. A correct harmony parse would never surface a channel/role name as a tool name.

## Expected behavior

For a forced `tool_choice` (`"required"` or `{ "type": "function", "function": { "name": ... } }`), gpt-oss should return the call in `choices[0].message.tool_calls` with `finish_reason: "tool_calls"`, exactly as it does for well-fitting prompts — never serialize the call as `content` text with `finish_reason: "stop"`.

## Repro environment

- Direct REST: `POST https://api.cloudflare.com/client/v4/accounts/{account}/ai/run/@cf/openai/gpt-oss-120b` (and `-20b`)
- `tool_choice` as the named-function form or `"required"`
- Reproduces both non-streaming and streaming
- `max_tokens: 2000` (not a truncation issue — `finish_reason` is `"stop"`, and `reasoning_content` is fully present)

## Client-side mitigation (and its limits)

In `workers-ai-provider` we added a conservative salvage: when a tool was forced, `tool_calls` is empty, and the `content` parses to JSON whose `name` matches a **requested** tool, we reinterpret it as a structured tool call (and report `finishReason: "tool-calls"`). We deliberately do **not** salvage when `name` is a channel/role/hallucination, to avoid fabricating bogus calls.

This only recovers the *envelope*. In the broken cases the **arguments are typically hallucinated** (e.g. `{"path":"coaching_feedback.txt"}` invented wholesale), so even a successful salvage yields low-quality arguments. The robust fix needs to be in the harmony parser server-side.

## Ask

Fix the harmony tool-call parsing for gpt-oss so forced tool calls are always emitted as structured `tool_calls` with `finish_reason: "tool_calls"`, instead of being serialized into the final/content channel as text.

Contributor guide

Open the contributing guide

Research direction

Start by reproducing the direct REST request against gpt-oss-120b or gpt-oss-20b with a forced tool and compare streaming and non-streaming responses. Trace the harmony parser in the serving layer described in the issue; done means forced calls consistently appear in tool_calls with finish_reason "tool_calls", never as content text with "stop".

Written by the indexing model from the issue text.

Assessment

Domain
ai, backend-api-design
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.