From ffeccbffb5fcfd11308014ae1870b127804b902f Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:52:25 +0900 Subject: [PATCH 1/3] fix(openai-chat): never synthesize a bare MiMo call from a cut stream A MiMo stream that closed after a complete bare block but before finish_reason or [DONE] reached the EOF flush with user-facing output, and standalone recovery emitted an executable call that structured calls on the same path would have refused as possible truncation. Recover only after a terminal signal; otherwise the block stays visible text. Co-authored-by: shirruy <63145023+shirruy@users.noreply.github.com> --- src/adapters/openai-chat.ts | 6 +++--- structure/providers/mimo-bare-calls.md | 5 +++-- .../openai/openai-chat-standalone-bare-block.test.ts | 11 +++++++++++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 87fdb1d4ab..9e794710c5 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -323,7 +323,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // Returns "terminate" when a pending call cannot be dispatched, so every flush site // stops the turn instead of emitting an unusable call. `closeToolCalls()` runs first, // so budget reservations are released for every pending call even on the early return. - const flushToolCalls = function* (): Generator { + const flushToolCalls = function* (terminal = true): Generator { const calls = closeToolCalls(); for (const call of calls) { // Ingest already proved `name` is a string; the typeof guard keeps this branch @@ -338,7 +338,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } const references = reconcileStructuredToolCalls(calls.map(call => ({ wireName: call.name, restoredName: toolNames.restore(call.name), argumentsText: call.args, freeformTool: freeformTools.get(call.name) })), toolCallContent.current(), !toolCallContent.releasedAnswerText && /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? "")); calls.forEach((call, index) => { call.args = references[index]!.argumentsText; }); - yield* toolCallContent.recoverStandalone(calls.length === 0, () => `call_${++toolCallSeq}`, name => toolNames.restore(name)); + yield* toolCallContent.recoverStandalone(calls.length === 0 && terminal, () => `call_${++toolCallSeq}`, name => toolNames.restore(name)); yield* toolCallContent.drain(references); for (const call of calls) { if (!call.id) call.id = `call_${++toolCallSeq}`; @@ -658,7 +658,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd yield* terminateWithError({ type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" }); return; } - if ((yield* flushToolCalls()) === "terminate") return; + if ((yield* flushToolCalls(sawFinish)) === "terminate") return; // A cut stream never synthesizes a bare call. const stopReason = stopReasonFor(finishReason); yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) }; } catch (error) { diff --git a/structure/providers/mimo-bare-calls.md b/structure/providers/mimo-bare-calls.md index 04fc4e2491..dc9af9e945 100644 --- a/structure/providers/mimo-bare-calls.md +++ b/structure/providers/mimo-bare-calls.md @@ -15,8 +15,9 @@ Identified wrappers never authorize synthetic calls or disappear because a bare recovered. Empty bodies, nested tool/function headers, and named or nested parameter markup stay inert. One malformed leading ` { + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://gateway.example.test/v1", apiKey: "key" })); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, + context: { messages: [{ role: "user", content: "Use exec", timestamp: 0 }], tools: [exec] } }); + const events: AdapterEvent[] = []; + // No finish_reason and no [DONE]: the connection closed after a complete-looking block. + for await (const event of adapter.parseStream(new Response(frame({ choices: [{ delta: { content: "Running.\n" + block() } }] })))) events.push(event); + expect(events.filter(event => event.type === "tool_call_start")).toEqual([]); + expect(visible(events)).toBe("Running.\n" + block()); +}); + test("64k adjacent bare blocks scan and fail closed within a bounded time", () => { const content = block().repeat(64_000); const start = performance.now(); From 7a03be2f65fea384060cb13c9eb10afe3dfe1393 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:52:25 +0900 Subject: [PATCH 2/3] fix(deepseek): upload only images from roles DeepSeek accepts System and assistant message images were uploaded and persisted even though DeepSeek rejects images in those roles. Restrict uploads to user and developer messages plus tool outputs. Co-authored-by: fontanierh <14199823+fontanierh@users.noreply.github.com> --- .../docs/reference/configuration/providers.md | 2 ++ src/server/responses/deepseek-image-files.ts | 2 +- tests/providers/deepseek-image-files.test.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 3c2d67f061..df96f1f42b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -25,6 +25,8 @@ partial uploads remain until expiry even if generation fails. The bounded refere credential-isolated and lasts at most 50 minutes (less if the provider reports earlier expiry). A retry can reuse successful uploads; another credential uploads its own copy. +Only `user` and `developer` message images and tool-output images are uploaded; DeepSeek rejects +images in other roles, so those are never persisted. Foreign gateways and Chat Completions are unchanged. Explicit `detail: low` stays inline because file references ignore it. Failures stop without discarding images or retrying generation. Repairs have a 60-second upload deadline, a 128 MiB input cap and at most two simultaneous preparations; diff --git a/src/server/responses/deepseek-image-files.ts b/src/server/responses/deepseek-image-files.ts index 9834a040af..a958b8301e 100644 --- a/src/server/responses/deepseek-image-files.ts +++ b/src/server/responses/deepseek-image-files.ts @@ -58,7 +58,7 @@ export async function deepSeekImageFileBody( for (const item of body.input) { if (!record(item)) continue; const parts = item.type === "function_call_output" || item.type === "custom_tool_call_output" - ? item.output : item.type === "message" || item.role ? item.content : undefined; + ? item.output : item.role === "user" || item.role === "developer" ? item.content : undefined; // DeepSeek rejects other roles' images. if (!Array.isArray(parts)) continue; for (const part of parts) { if (bytes <= TARGET_BYTES) break; diff --git a/tests/providers/deepseek-image-files.test.ts b/tests/providers/deepseek-image-files.test.ts index b0ebda3819..43508398b9 100644 --- a/tests/providers/deepseek-image-files.test.ts +++ b/tests/providers/deepseek-image-files.test.ts @@ -173,6 +173,18 @@ describe("DeepSeek image request body limits", () => { undefined, optIn)).toBeUndefined(); }); + test("images in roles DeepSeek rejects are never uploaded", async () => { + for (const role of ["system", "assistant"]) { + const original = request(role); + const body = JSON.parse(original.body); + body.input[0].role = role; + body.input[1].output[0].detail = "low"; + original.body = JSON.stringify(body); + expect(await deepSeekImageFileBody(original, (async () => { throw new Error("unexpected upload"); }) as typeof fetch, + undefined, optIn)).toBeUndefined(); + } + }); + test("credential changes while uploads queue prevent stale physical sends", async () => { const restoreFetch = globalThis.fetch; const releaseSpend = acquireOwnedSpendHome(); From 01c55e57b83ed1a66cd94fad2e642b10d7086aa8 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:58:41 +0900 Subject: [PATCH 3/3] fix(openai-chat): make the first terminal flush the only bare-call recovery decision Content that arrives after finish_reason kept standalone recovery enabled, so a later cut-off block could still execute at EOF or [DONE]. Co-authored-by: shirruy <63145023+shirruy@users.noreply.github.com> --- .../openai-chat/standalone-bare-tool-calls.ts | 3 ++- structure/providers/mimo-bare-calls.md | 4 ++-- .../openai-chat-standalone-bare-block.test.ts | 13 +++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/adapters/openai-chat/standalone-bare-tool-calls.ts b/src/adapters/openai-chat/standalone-bare-tool-calls.ts index 6688b9a731..00321a12f1 100644 --- a/src/adapters/openai-chat/standalone-bare-tool-calls.ts +++ b/src/adapters/openai-chat/standalone-bare-tool-calls.ts @@ -87,7 +87,8 @@ export class StandaloneBareToolCallBuffer extends SerializedToolCallContentBuffe } const held = recoverStandaloneBareBlocks(this.current(), this.freeformTools, this.context); this.remember(held); - if (this.disabled || this.candidates.length === 0) return; + // The first terminal flush is the only recovery decision; content after it cannot authorize a call. + if (this.disabled || this.candidates.length === 0) { this.disabled = true; return; } yield* this.drainRanges(held); for (const call of this.candidates) yield* callEvents(call, nextId(), restore); this.clearCandidates(); diff --git a/structure/providers/mimo-bare-calls.md b/structure/providers/mimo-bare-calls.md index dc9af9e945..6b53209afb 100644 --- a/structure/providers/mimo-bare-calls.md +++ b/structure/providers/mimo-bare-calls.md @@ -16,8 +16,8 @@ recovered. Empty bodies, nested tool/function headers, and named or nested param stay inert. One malformed leading ` { + for (const tail of ["", "data: [DONE]\n\n"]) { + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://gateway.example.test/v1", apiKey: "key" })); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, + context: { messages: [{ role: "user", content: "Use exec", timestamp: 0 }], tools: [exec] } }); + const body = frame({ choices: [{ delta: { content: "Done." }, finish_reason: "stop" }] }) + + frame({ choices: [{ delta: { content: "\n" + block() } }] }) + tail; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) events.push(event); + expect(events.filter(event => event.type === "tool_call_start")).toEqual([]); + } +}); + test("64k adjacent bare blocks scan and fail closed within a bounded time", () => { const content = block().repeat(64_000); const start = performance.now();