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
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,11 @@ configurations can set this option on their `opencodex` provider as well.
Pi can omit session affinity when `cacheRetention` is `none`; enable cache retention
when a stable upstream session is required.

**MiMo tool-call echoes.** On OpenCode Go and other Chat Completions routes, a bare
`<tool_call>` block is hidden when it duplicates one structured call to the same tool
with the same effective input. If the input differs or several calls could explain
the block, the markup remains visible. Tool execution still uses the structured call.

**OpenCode Zen** (`opencode-zen`) and the keyless **OpenCode Free** preset share
`https://opencode.ai/zen/v1`. Free models on that gateway often hit a short-window burst
limit around 15–20 requests/minute (community-measured; OpenCode does not publish RPM).
Expand Down
9 changes: 6 additions & 3 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ function canSerializeOpenAIChatServiceTier(

export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
let lastRequestedModelId: string | undefined;
let freeformToolsByWireName = new Map<string, { name: string; namespace?: string }>();
return withOpenAIChatToolNames(toolNames => ({
name: "openai-chat",

Expand All @@ -102,6 +103,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
lastRequestedModelId = parsed.modelId;
const { url, headers, hasCredential } = openAIChatTransport(provider);
const messages = toolNames.messages(parsed, provider.baseUrl, messagesToChatFormat(parsed, provider));
freeformToolsByWireName = new Map(parsed.context.tools?.filter(tool => tool.freeform)
.map(tool => [toolNames.registry().alias(tool), { name: tool.name, namespace: tool.namespace }] as const) ?? []);
const finish = (): AdapterRequest => {
const tools = toolsToChatFormatForProvider(parsed, provider, toolNames.registry());
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider, toolNames.registry());
Expand Down Expand Up @@ -338,7 +341,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
}
}
// Held markup is released only now, as one batch per response: the doubled-input repair needs every call.
const references = reconcileStructuredToolCalls(calls.map(call => ({ wireName: call.name, restoredName: toolNames.restore(call.name), argumentsText: call.args })), toolCallContent.current());
const references = reconcileStructuredToolCalls(calls.map(call => ({ wireName: call.name, restoredName: toolNames.restore(call.name), argumentsText: call.args, freeformTool: freeformToolsByWireName.get(call.name) })), toolCallContent.current());
calls.forEach((call, index) => { call.args = references[index]!.argumentsText; });
yield* toolCallContent.drain(references);
for (const call of calls) {
Expand Down Expand Up @@ -773,7 +776,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
const contentEnd = events.length;
const answerText = events.slice(contentStart).map(event => (event.type === "text_delta" ? event.text : "")).join("");
// Each call holds the delta event it emitted, so the batch repair sets its arguments later.
const structuredCalls: { wireName: string; restoredName: string; argumentsText: string; delta: Extract<AdapterEvent, { type: "tool_call_delta" }> }[] = [];
const structuredCalls: { wireName: string; restoredName: string; argumentsText: string; freeformTool?: { name: string; namespace?: string }; delta: Extract<AdapterEvent, { type: "tool_call_delta" }> }[] = [];
const rawToolCalls = msg.tool_calls;
if (rawToolCalls !== undefined && rawToolCalls !== null) {
if (!Array.isArray(rawToolCalls)) {
Expand All @@ -797,7 +800,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
}
const delta: Extract<AdapterEvent, { type: "tool_call_delta" }> = { type: "tool_call_delta", arguments: args };
structuredCalls.push({ wireName: name, restoredName: toolNames.restore(name), argumentsText: args, delta });
structuredCalls.push({ wireName: name, restoredName: toolNames.restore(name), argumentsText: args, freeformTool: freeformToolsByWireName.get(name), delta });
events.push({ type: "tool_call_start", id, name: toolNames.restore(name) }, delta, { type: "tool_call_end" });
}
}
Expand Down
63 changes: 43 additions & 20 deletions src/adapters/openai-chat/serialized-tool-call-content.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { TranslatorBudget } from "../../lib/translator-budget";
import type { AdapterEvent } from "../../types";
import { repairFreeformToolInput } from "../../responses/apply-patch-envelope";

const OPEN_TAG = "<tool_call>";
const CLOSE_TAG = "</tool_call>";
Expand Down Expand Up @@ -30,6 +31,7 @@ export interface SerializedToolCall {
export interface StructuredToolCallReference {
names: ReadonlySet<string>;
argumentsText: string;
freeformTool?: { name: string; namespace?: string };
}

const BLOCK_HEADER = /<tool_call>\s*<function=([^>\r\n]+)>/y;
Expand Down Expand Up @@ -322,16 +324,25 @@ export class SerializedToolCallContentBuffer {
}
}

/** Reads only a string input from a JSON object; other argument shapes cannot prove duplication. */
function inputFromArguments(argumentsText: string): string | undefined {
/** Reads a wrapped input or raw arguments from a declared freeform tool; neither path rewrites them. */
function inputFromArguments(
argumentsText: string,
freeformTool?: { name: string; namespace?: string },
): string | undefined {
try {
const parsed = JSON.parse(argumentsText) as unknown;
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
const input = (parsed as Record<string, unknown>).input;
return typeof input === "string" ? input : undefined;
const input = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>).input
: undefined;
if (typeof input === "string") return input;
} catch {
return undefined;
// Chat gateways can send custom-tool input as raw text.
}
if (!freeformTool) return undefined;
// Match raw input only when the Responses bridge dispatches these same bytes. It can unwrap
// alternate fields or repair freeform input using the declared tool's name and namespace.
return repairFreeformToolInput(argumentsText, freeformTool.name, freeformTool.namespace) === argumentsText
? argumentsText : undefined;
}

/** One wrapping newline after the function header is template layout, not input (vLLM `_trim_wrapping_newlines`). */
Expand All @@ -348,10 +359,20 @@ function agreesWithRepeatedBlock(
structured: StructuredToolCallReference,
repeated: SerializedToolCall,
): boolean {
const input = structured.names.has(repeated.name) ? inputFromArguments(structured.argumentsText) : undefined;
const input = structured.names.has(repeated.name) ? inputFromArguments(structured.argumentsText, structured.freeformTool) : undefined;
return input !== undefined && freeformBody(input) === freeformBody(repeated.body);
}

/** A second call can explain a repeated pair even when its arguments cannot safely be rewritten. */
function hasDoubledInput(structured: StructuredToolCallReference, repeated: SerializedToolCall): boolean {
if (!structured.names.has(repeated.name)) return false;
const input = inputFromArguments(structured.argumentsText, structured.freeformTool);
if (input === undefined) return false;
const body = freeformBody(repeated.body);
const normalized = freeformBody(input);
return normalized === body + body || normalized === body + "\n" + body;
}

/** The `[start, end)` ranges of blocks whose function identity and freeform input match a dispatched call. */
function duplicatedSerializedToolCallRanges(
text: string,
Expand All @@ -361,14 +382,16 @@ function duplicatedSerializedToolCallRanges(
if (structuredCalls.length === 0) return [];
const repeated = repeatedCallIn(text, context);
if (repeated) {
// Without a single agreeing call the pair is ambiguous, so no shape of it is suppressed.
// A doubled call beside an agreeing call leaves the pair ambiguous. If reduction was
// refused, keep the markup too: otherwise the visible text and executable call disagree.
const matching = structuredCalls.filter(structured => agreesWithRepeatedBlock(structured, repeated));
return matching.length === 1 ? [{ start: repeated.start, end: repeated.end }] : [];
const doubled = structuredCalls.some(structured => structured !== matching[0] && hasDoubledInput(structured, repeated));
return matching.length === 1 && !doubled ? [{ start: repeated.start, end: repeated.end }] : [];
}
return callsIn(text, context).filter(call => {
const body = freeformBody(call.body);
return structuredCalls.some(structured => {
const input = structured.names.has(call.name) ? inputFromArguments(structured.argumentsText) : undefined;
const input = structured.names.has(call.name) ? inputFromArguments(structured.argumentsText, structured.freeformTool) : undefined;
return input !== undefined && freeformBody(input) === body;
});
});
Expand Down Expand Up @@ -458,24 +481,24 @@ export interface StructuredToolCallInput {
wireName: string;
restoredName: string;
argumentsText: string;
freeformTool?: { name: string; namespace?: string };
}

/**
* Repairs the arguments of every structured call in one response against the visible text that
* response carried, and returns them in input order. The per-call prefix repair stands alone,
* because the markup it proves is matched against that call's own repaired input. The
* doubled-input reduction is applied only when exactly ONE call in the batch qualifies: it either
* carries the doubled shape or already agrees with the repeated block body. It rewrites executable
* arguments, and a second qualifying call leaves the block ambiguous, so the uniqueness proof has
* to cover the whole batch rather than one call at a time.
* doubled-input reduction is applied only when exactly one call in the batch explains the repeated
* pair, including doubled shapes that cannot themselves be rewritten. It rewrites executable
* arguments, so a second explanatory call leaves the pair ambiguous.
*/
export function reconcileStructuredToolCalls(
calls: readonly StructuredToolCallInput[],
serializedText: string,
): StructuredToolCallReference[] {
const references = calls.map(call => {
const names = new Set([call.wireName, call.restoredName]);
return { names, argumentsText: repairArgumentsDuplicatedBesideSerializedCall(call.argumentsText, names, serializedText) };
return { names, freeformTool: call.freeformTool, argumentsText: repairArgumentsDuplicatedBesideSerializedCall(call.argumentsText, names, serializedText) };
});
return reduceUnambiguousDoubledInput(references, serializedText);
}
Expand All @@ -486,9 +509,8 @@ export function reconcileStructuredToolCalls(
* alone. A call whose input already equals the repeated body is a competing explanation, not a
* bystander: both readings account for the pair and the response never picks one, so a batch with
* two qualifying calls keeps every argument exactly as sent. The reduction then rewrites nothing,
* and the markup is left to the range matcher, which suppresses the pair only when exactly one
* call already agrees. A lone qualifying call is always the doubled one, because a call that
* already agrees leaves nothing to reduce.
* and the range matcher keeps the markup when a doubled call competes with an agreeing call.
* An already-agreeing call leaves nothing to reduce.
*/
function reduceUnambiguousDoubledInput(
references: readonly StructuredToolCallReference[],
Expand All @@ -499,13 +521,14 @@ function reduceUnambiguousDoubledInput(
const candidates = references.map(reference => ({
reduction: doubledInputReduction(reference.argumentsText, reference.names, repeated),
explains: agreesWithRepeatedBlock(reference, repeated),
doubled: hasDoubledInput(reference, repeated),
}));
if (candidates.filter(candidate => candidate.explains || candidate.reduction !== undefined).length !== 1) {
if (candidates.filter(candidate => candidate.explains || candidate.doubled).length !== 1) {
return [...references];
}
return references.map((reference, index) => {
const reduction = candidates[index]!.reduction;
return reduction === undefined ? reference : { names: reference.names, argumentsText: reduction };
return reduction === undefined ? reference : { ...reference, argumentsText: reduction };
});
}

Expand Down
16 changes: 11 additions & 5 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,11 @@ entry. `src/adapters/openai-chat/serialized-tool-call-content.ts` recognizes bar
start of a line outside Markdown fences; inline, quoted and indented examples remain unchanged.
It holds a possible serialized block, resumes ordinary text delivery when the header cannot match,
and removes the block only when its function name and
freeform body match a structured call's parsed `input` in the same response.
freeform body match a structured call's parsed `input` or exact raw arguments from a declared
freeform/custom tool in the same response. Valid JSON primitives, arrays, and objects can be raw
freeform input too; the declared tool name and namespace determine whether the bridge would
unwrap an alternate field, and JSON text that changes on dispatch is not treated as executed input.
Malformed JSON on an ordinary function is not raw input.
A block may close a freeform body with a stray `</parameter>` and may omit `</function>`, and one
newline after the function header is template layout, so MiMo's echoes of those shapes match too
(#5724). Blocks are read by delimiter scan in linear time: the first `</tool_call>` preceded by
Expand All @@ -336,10 +340,12 @@ arguments with the same freeform body, the adapter keeps the JSON suffix only wh
prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact.
Two immediately adjacent identical bare blocks, with optional trailing whitespace after the pair,
are suppressed only when exactly one structured call matches their function name and carries their
body as `input`, either as one copy or as two copies joined directly or by one newline. Reducing a
doubled `input` requires an arguments object with no keys besides `input`; extra keys leave it
unchanged. Unrelated structured calls do not prevent suppression, and other repeated shapes remain
unchanged.
body as `input` or exact raw arguments, or when one doubled `input` can be reduced to that body.
Reducing a doubled `input` requires an arguments object with no keys besides `input`; extra keys
leave it unchanged. When another same-function call also matches or doubles the body, neither
arguments nor markup are changed because the response is ambiguous. An empty block and its own
empty-input call do not create a second explanation. Unrelated structured calls do not
prevent suppression, and other repeated shapes remain unchanged.
Silent held-content frames emit adapter heartbeats. Terminal errors and transport read failures
drain all held text, including matching serialized blocks, because pending tools are not dispatched.
The held bytes use the shared translator budget. The streaming hold is bounded (`ingestStreaming`): once a closed block is followed by more than 8 KiB of prose with no block open after it, or held text plus queued events would pass 4 MiB, everything held is released in order with nothing suppressed, so an unmatched block no longer delays the rest of the answer to the end of the turn. A duplicate is the tail of the content, so its reconciliation is unaffected; past either bound the stream prefers delivery (the pre-#5548 raw markup) over suppression. Buffered responses keep the unbounded `ingest` because their structured calls are already known (`tests/adapters/openai/openai-chat-serialized-tool-call-hold-bound.test.ts`). For a model opted into inline `<think>` splitting,
Expand Down
Loading
Loading