Skip to content

fix(providers): cloudflare-ai flattens message content unconditionally, but the #2539 constraint is model-scoped — this blocks image input to Cloudflare vision models - #12002

Merged
diegosouzapw merged 2 commits into
diegosouzapw:release/v3.8.51from
davidlinfr:fix/cloudflare-ai-model-scoped-content-parts
Aug 30, 2026
Merged

diegosouzapw merged 2 commits into
diegosouzapw:release/v3.8.51from
davidlinfr:fix/cloudflare-ai-model-scoped-content-parts

Conversation

@davidlinfr

Copy link
Copy Markdown
Contributor

Summary

open-sse/executors/cloudflare-ai.ts flattens every message.content array into a plain
string, and throws when any part is non-text. The comment explains why:

the Workers AI /ai/v1/chat/completions endpoint requires each message content to be a
plain string; it rejects the OpenAI content-part array shape ([{ type:"text", text }])
with HTTP 400 (#2539)

The constraint is real, but it belongs to the model, not to the endpoint. Measured today
against the exact URL buildUrl() produces, same payload, only the model changing:

Model content = string content = all-text part array
@cf/mistralai/mistral-small-3.1-24b-instruct 200 200
@cf/meta/llama-4-scout-17b-16e-instruct 200 200
@cf/meta/llama-3.3-70b-instruct-fp8-fast 200 200
@cf/qwen/qwen2.5-coder-32b-instruct (text-only) 200 400

The 400 is AiError: Bad input: … oneOf at '/' not met … — the same error family as #2539,
alive today. Multimodal models declare content: string | array; text-only models declare
content: string. So the guard is unconditional while the constraint is not, and the
#6390 throw it entails refuses image input to vision models that would accept it.

To be explicit about what this PR does not propose: deleting flattenContent outright
would regress every text-only model. I measured that before writing this. The #6390 throw
is also correct given the flattening — refusing beats silently dropping an image. The
defect is one level up: the flattening is applied where it is not needed.

Reproduction

All calls against https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1/chat/completions,
on 2026-08-29. Model is @cf/mistralai/mistral-small-3.1-24b-instruct unless stated:

# payload HTTP What it establishes
T0 content = "plain string" 200 control — credentials and endpoint work
T1 content = [{"type":"text","text":"…"}] 200 array accepted on a multimodal model
T2 content = [{"type":"text",…},{"type":"image_url",…}] 200 image text read back correctly
T3 content = [{"type":"not_a_real_type","blob":"x"}] 400 negative control — still validates
T4 3 messages (user/assistant/user), all content arrays 200 multi-turn, as in #2539
T5 same 3 messages, messages[2] missing role 400 messages[2].role Required
T6 T1 on @cf/qwen/qwen2.5-coder-32b-instruct 400 the constraint, alive, model-scoped

T2 was only run on multimodal models (mistral-small-3.1, llama-4-scout); sending an image
to a text-only model would not establish anything.

Two notes on #2539 itself, which I read in full:

  • Its error is an aggregated oneOf, and one line reads
    required properties at '/messages/2' are 'role,content'. T5 reconstructs that shape — a
    message missing role — and gets a 400 naming exactly that field. A malformed message is
    sufficient on its own to fail the request, whatever the other messages' content looks
    like. (T5 is a reconstruction from the reported error, not the original payload.)
  • The surrounding lines contradict each other — messages/0 and /1 faulted for being
    arrays "not in string", messages/2 for being a string "not in array". T3 shows the same
    aggregation noise from a single bad part. Those lines are branch noise, not a diagnosis.

I am not claiming #2539 was misdiagnosed with certainty. I am claiming the fix it produced
is broader than the constraint it was aimed at.

Minimal T6, the one that matters for scope:

for M in "@cf/mistralai/mistral-small-3.1-24b-instruct" "@cf/qwen/qwen2.5-coder-32b-instruct"; do
  printf '%s ' "$M"
  curl -s -o /dev/null -w '%{http_code}\n' \
    "https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1/chat/completions" \
    -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
    -d "{\"model\":\"$M\",\"stream\":false,\"messages\":[{\"role\":\"user\",
         \"content\":[{\"type\":\"text\",\"text\":\"Reply exactly: OK\"}]}]}"
done
# @cf/mistralai/mistral-small-3.1-24b-instruct 200
# @cf/qwen/qwen2.5-coder-32b-instruct 400

### Scope

Two files. `open-sse/executors/cloudflare-ai.ts` — `transformRequest()` only; the flatten
helper is split into `isTextPart` / `flattenTextParts` so the condition reads as the rule it
encodes. `tests/unit/cloudflare-ai-image-parts-6390.test.ts` — the `#6390` expectation is
restated (the attachment must survive, which is what #6390 asked for) and a `#2539`
no-regression witness is added on a text-only model. No refactor in passing, no other
provider touched, no behaviour change for plain-string or all-text content.

### TDD proof (Hard Rule #18)

Tests written first, against unchanged source — **red**, and for the right reason:

not ok 1 - transformRequest passes image_url content parts through untouched (#6390)
error: 'Cloudflare Workers AI chat endpoint does not accept image/non-text content parts …'
not ok 2 - transformRequest never silently drops a non-text part (#6390)
error: 'Cloudflare Workers AI chat endpoint does not accept image/non-text content parts …'

tests 11 · # pass 9 · # fail 2


Same command after the `transformRequest()` change — **green**:

tests 11 · # pass 11 · # fail 0


### Gates

node --import tsx/esm … --test tests/unit/cloudflare-ai-image-parts-6390.test.ts
tests/unit/executor-cloudflare-ai.test.ts 11/11 pass
npm run typecheck:core clean
npx eslint --suppressions-location config/quality/eslint-suppressions.json
open-sse/executors/cloudflare-ai.ts tests/unit/cloudflare-ai-image-parts-6390.test.ts
ESLint: No issues found
node scripts/check/check-file-size.mjs OK
node scripts/check/check-complexity.mjs OK — 2672 (baseline 2774)


`pre-commit` also ran prettier, `eslint --fix`, `check:docs-sync`,
`check:any-budget:t11` and `check:tracked-artifacts` — all clean.

`tests/unit/executor-cloudflare-ai.test.ts` and
`tests/unit/cloudflare-ai-image-parts-6390.test.ts` are the only files importing this
executor, and the thrown message is referenced nowhere else in the codebase (only in
`CHANGELOG.md`), so nothing downstream keys on it.

### Changelog

Fragment added under `changelog.d/fixes/` once this PR has a number.

… text

The diegosouzapw#2539 constraint is carried by the model schema, not by the endpoint.
Measured 2026-08-29 against /accounts/{id}/ai/v1/chat/completions with an
all-text OpenAI content-part array:

  @cf/mistralai/mistral-small-3.1-24b-instruct   200
  @cf/meta/llama-4-scout-17b-16e-instruct        200
  @cf/meta/llama-3.3-70b-instruct-fp8-fast       200
  @cf/qwen/qwen2.5-coder-32b-instruct            400  (AiError, oneOf at '/')

Text-only models declare `content: string`; multimodal models declare
`content: string | array`. transformRequest() flattened every array and
threw on the first non-text part (diegosouzapw#6390), so image input was refused for
every Cloudflare model alike, including the ones that accept it.

Flattening all-text arrays is kept — it is the one shape every model
accepts. An array carrying a non-text part is now passed through instead
of throwing: an image is only meaningful to a multimodal model, and those
accept the array. A text-only target gets Cloudflare's own 400, which says
more than a pre-emptive gateway refusal.

diegosouzapw#6390's requirement is preserved: the attachment is never silently
dropped. The regression test now asserts it survives transformRequest,
and a new witness pins an all-text array on a text-only model to the
flattened-string path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@diegosouzapw
diegosouzapw merged commit 476b20b into diegosouzapw:release/v3.8.51 Aug 30, 2026
3 checks passed
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…y, but the diegosouzapw#2539 constraint is model-scoped — this blocks image input to Cloudflare vision models (diegosouzapw#12002)

Corrige o achatamento incondicional de conteúdo de mensagem no cloudflare-ai — a restrição diegosouzapw#2539 é model-scoped, não global, e estava bloqueando entrada de imagem em modelos de visão da Cloudflare. Teste próprio atualizado. Validado no worktree combinado. Obrigado!
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants