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/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/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/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/structure/providers/mimo-bare-calls.md b/structure/providers/mimo-bare-calls.md index 04fc4e2491..6b53209afb 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("a bare block arriving after the terminal finish_reason never executes", async () => { + 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(); 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();