diff --git a/CHANGELOG.md b/CHANGELOG.md index d1d6cd5d69..1c854cbdfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral ### 🐛 Bug Fixes +- **fix(providers):** `cloudflare-ai` no longer silently drops image/non-text content parts ([#6390](https://github.com/diegosouzapw/OmniRoute/issues/6390)) — `transformRequest()`'s `flattenContent()` (added for #2539 to satisfy the Workers AI `/ai/v1/chat/completions` plain-string `content` requirement) mapped any non-text OpenAI content part (e.g. `image_url`) to `""` and joined the rest, so a request carrying an image quietly went out as text-only with the attachment gone and no error surfaced. It now throws a clear error on the first non-text part instead of dropping it silently, which the existing top-level `chatCore.ts` catch already routes through `buildErrorBody()`/`sanitizeErrorMessage()` (same pattern as `buildUrl()`'s missing-Account-ID error). Regression guard: `tests/unit/cloudflare-ai-image-parts-6390.test.ts`. - **fix(providers):** stop Antigravity connections from falsely reporting **all-accounts quota-exhausted** ([#6295](https://github.com/diegosouzapw/OmniRoute/issues/6295)) — `genericQuotaFetcher.ts::percentUsedForQuota()` ignored the `fractionReported` flag and defaulted an unreported model's `remainingPercentage` to 0, which computed as 100% used; since `convertUsageToQuotaInfo()` takes the worst-case window across a connection, a single model with no reported fraction dragged the whole account into `limitReached` and `quotaPreflight` skipped it. `percentUsedForQuota()` now returns `null` (unknown, window ignored) whenever `fractionReported === false`, before falling back to `remainingPercentage`. Regression guard: `tests/unit/generic-quota-fetcher.test.ts`. - **fix(providers):** grok-cli (Grok Build) now strips `reasoning_effort`/`reasoning` before forwarding the request ([#6288](https://github.com/diegosouzapw/OmniRoute/issues/6288)) — Claude Code sends `reasoning_effort` on every request (routing the Opus slot), which Grok Build's upstream chat-proxy endpoint rejects with a 400; `transformRequest()`'s existing `UNSUPPORTED` sampling-param strip list (#5273) never covered it. Regression guard: `tests/unit/grok-cli-reasoning-strip-6288.test.ts`. - **fix(cli):** `omniroute serve` no longer hangs silently on a readiness timeout ([#6321](https://github.com/diegosouzapw/OmniRoute/issues/6321)) — the child server's stdout was piped to `"ignore"` whenever `--log`/`OMNIROUTE_SHOW_LOG` wasn't set (the default), discarding any debug output, and `runWithSupervisor`'s `waitForServer(...).then((up) => { if (up) {...} })` had no `else` branch, so a boot that never became ready produced zero further output after "⏳ Starting server...". Stdout is now buffered alongside stderr (`ServerSupervisor.getRecentLog()`), and a timeout prints a clear diagnostic plus the buffered output instead of staying silent. Does not by itself explain why boot never completes on a given machine — see the issue for further reproduction. Regression guard: `tests/unit/cli-serve-readiness-timeout-6321.test.ts`. diff --git a/open-sse/executors/cloudflare-ai.ts b/open-sse/executors/cloudflare-ai.ts index cd1b184f8c..c6173488df 100644 --- a/open-sse/executors/cloudflare-ai.ts +++ b/open-sse/executors/cloudflare-ai.ts @@ -75,13 +75,24 @@ export class CloudflareAIExecutor extends BaseExecutor { // shape (`[{ type:"text", text }]`) with HTTP 400 (#2539). Flatten text parts to a string. if (!Array.isArray(body.messages)) return body; + // #6390: the endpoint has no way to carry image/non-text parts once flattened to a + // string, so previously any non-text part (e.g. image_url) was silently mapped to "" + // and the image quietly disappeared from the outgoing request. Refuse instead of + // silently dropping data — this throws a plain Error which the caller (chatCore.ts) + // already routes through buildErrorBody()/sanitizeErrorMessage() before it reaches + // the client, matching the existing buildUrl() missing-accountId error above. const flattenContent = (content: unknown): unknown => { if (typeof content === "string" || !Array.isArray(content)) return content; return content .map((part) => { if (!part || typeof part !== "object") return ""; const p = part as Record; - return p.type === "text" && typeof p.text === "string" ? p.text : ""; + if (p.type === "text" && typeof p.text === "string") return p.text; + throw new Error( + "Cloudflare Workers AI chat endpoint does not accept image/non-text content parts " + + `(got type "${typeof p.type === "string" ? p.type : "unknown"}"). ` + + "Remove image/file attachments or route this request to a vision-capable provider." + ); }) .join(""); }; diff --git a/tests/unit/cloudflare-ai-image-parts-6390.test.ts b/tests/unit/cloudflare-ai-image-parts-6390.test.ts new file mode 100644 index 0000000000..1ae3847fbb --- /dev/null +++ b/tests/unit/cloudflare-ai-image-parts-6390.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { CloudflareAIExecutor } from "../../open-sse/executors/cloudflare-ai.ts"; + +// Regression for #6390: the Workers AI /ai/v1/chat/completions endpoint only accepts a +// plain-string `content` field. transformRequest() used to flatten every non-text content +// part (e.g. image_url) to an empty string and silently join the rest — the image (or any +// other non-text attachment) vanished from the outgoing request with no error surfaced to +// the caller. transformRequest must now refuse the request instead of silently dropping data. +test("CloudflareAIExecutor.transformRequest throws a clear error on image_url content parts (#6390)", () => { + const executor = new CloudflareAIExecutor(); + const body = { + model: "@cf/meta/llama-3.3-70b-instruct", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe this image" }, + { type: "image_url", image_url: { url: "https://example.com/cat.png" } }, + ], + }, + ], + }; + + assert.throws( + () => executor.transformRequest("@cf/meta/llama-3.3-70b-instruct", body, false, null), + /does not accept image|non-text content/i + ); +}); + +test("CloudflareAIExecutor.transformRequest still flattens plain text-part messages (#6390 no-regression)", () => { + const executor = new CloudflareAIExecutor(); + const body = { + model: "@cf/meta/llama-3.3-70b-instruct", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "hello " }, + { type: "text", text: "world" }, + ], + }, + { role: "assistant", content: "plain stays plain" }, + ], + }; + + const out = executor.transformRequest("@cf/meta/llama-3.3-70b-instruct", body, false, null); + const messages = out.messages as Array<{ content: unknown }>; + + assert.equal(messages[0].content, "hello world"); + assert.equal(messages[1].content, "plain stays plain"); +});