Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AdapterEvent, "continue" | "terminate"> {
const flushToolCalls = function* (terminal = true): Generator<AdapterEvent, "continue" | "terminate"> {
const calls = closeToolCalls();
for (const call of calls) {
// Ingest already proved `name` is a string; the typeof guard keeps this branch
Expand All @@ -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}`;
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require the terminal signal to follow the bare block

When a provider emits finish_reason and then sends another content delta before EOF, finishReason remains set, so this passes true even though the candidate bare block arrived after the only terminal signal. The earlier finish flush finds no candidate and leaves standalone recovery enabled, allowing a later cut-off <tool_call> to execute—the truncated-response hazard this change is intended to prevent. Track terminal state relative to newly ingested content, or disable recovery after the first terminal flush, instead of using the response-wide sawFinish value.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

const stopReason = stopReasonFor(finishReason);
yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
} catch (error) {
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/openai-chat/standalone-bare-tool-calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/server/responses/deepseek-image-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions structure/providers/mimo-bare-calls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<parameter=` retains existing freeform-body normalization.
Ordinary functions, absent tools, hyphenated MiMo IDs, literals and prose without a block remain
unchanged. A structured call disables standalone recovery for the entire response; errors and
cancellation dispatch nothing. Each accepted bare block becomes one start/delta/end sequence
unchanged. A structured call disables standalone recovery for the entire response; errors,
cancellation, a stream that ends without `finish_reason` or `[DONE]`, and content after the first
terminal signal dispatch nothing (the block stays visible text). Each accepted bare block becomes one start/delta/end sequence
with restored tool identity and JSON `{input}` arguments. Only accepted bare ranges are removed;
the structured-call repeated-pair ambiguity rule does not govern synthetic calls.

Expand Down
24 changes: 24 additions & 0 deletions tests/adapters/openai/openai-chat-standalone-bare-block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,30 @@ test.each(["other-model", "mimo-v2.6-pro"])("streamed %s keeps identified contro
expect(budget.snapshot().currentBytes).toBe(0);
});

test("a stream cut without a terminal signal never executes a bare block", async () => {
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();
Expand Down
12 changes: 12 additions & 0 deletions tests/providers/deepseek-image-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading