Skip to content
Draft
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
40 changes: 40 additions & 0 deletions src/adapters/empty-tool-output-annotation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Shared wire text and emptiness contract for present-but-empty tool outputs.
*
* Both the OpenAI Chat and Responses adapters use this module so the two wires
* cannot drift again: only a pure text/refusal part array whose joined content
* trims empty is "present but empty". Image, file, encrypted-content and any
* other non-text part is real output and is never replaced by the annotation.
*/

/** Wire text used when a present-but-empty tool output must stay visible to the model. */
export const EMPTY_TOOL_OUTPUT_ANNOTATION =
"[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input.";

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
* True when every part is text/refusal and the joined text/refusal content trims
* empty. An empty array is the array twin of a blank string. Any image, file,
* encrypted-content or other non-text part makes the array non-empty so the
* model still receives the real payload.
*/
export function isWhitespaceOnlyTextPartArray(parts: readonly unknown[]): boolean {
if (parts.length === 0) return true;
let joined = "";
for (const part of parts) {
if (!isPlainObject(part)) return false;
if (part.type === "text" && typeof part.text === "string") {
joined += part.text;
continue;
}
if (part.type === "refusal" && typeof part.refusal === "string") {
joined += part.refusal;
continue;
}
return false;
}
return joined.trim() === "";
}
22 changes: 18 additions & 4 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { isDebugEnabled } from "../lib/debug-settings";
import { isCyberPolicyCode } from "../lib/errors";
import { redactSecretString } from "../lib/redact";
import { contentPartsToText } from "./image";
import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation";
import { identifyRoutedModel } from "./identity";
import { peekReasoningForCall } from "../responses/reasoning-replay-cache";
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
Expand Down Expand Up @@ -583,9 +584,22 @@ function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean {
* being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
* URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
*/
function toolResultTextForWire(content: string | OcxContentPart[]): string {
if (typeof content === "string") return content;
function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty = false): string {
// An empty content array is a present-but-empty result; `contentPartsToText` would
// otherwise fall back to the "[image]" marker and hide the emptiness from the model.
if (annotateEmpty && Array.isArray(content) && content.length === 0) return EMPTY_TOOL_OUTPUT_ANNOTATION;
if (typeof content === "string") {
if (annotateEmpty && content.trim() === "") return EMPTY_TOOL_OUTPUT_ANNOTATION;
return content;
}
const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join("");
// A whitespace-only text-part array is the array twin of a blank string; the
// shared emptiness contract (same module as the Responses adapter) annotates it
// instead of forwarding whitespace the model silently accepts. Image parts and
// any other non-text part keep the array non-empty.
if (annotateEmpty && isWhitespaceOnlyTextPartArray(content)) {
return EMPTY_TOOL_OUTPUT_ANNOTATION;
}
if (text) {
const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length;
return `${text}${"[image]".repeat(untransportableImages)}`;
Expand Down Expand Up @@ -772,7 +786,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
out.push({
role: "tool",
tool_call_id: toolCallId,
content: toolResultTextForWire(msg.content),
content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true),
});
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
pendingToolCalls.splice(matchIdx, 1);
Expand Down Expand Up @@ -817,7 +831,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
out.push({
role: "tool",
tool_call_id: toolCallId,
content: toolResultTextForWire(msg.content),
content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true),
});
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
flushToolResultImages();
Expand Down
34 changes: 34 additions & 0 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-co
import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat";
import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat";
import { openaiResponsesUrl } from "./openai-responses-url";
import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation";
import { normalizeXaiResponsesWebSearch } from "./xai-web-search";
import {
createAdapterTierMetadata,
Expand Down Expand Up @@ -710,6 +711,36 @@ function toolOutputText(output: unknown): string {
}).filter(Boolean).join("\n");
}

/** True when a Responses tool output item is present but carries no usable content. */
function isToolOutputEmpty(output: unknown): boolean {
if (typeof output === "string") return output.trim() === "";
if (Array.isArray(output)) {
// Mirror the Chat wire rule through the shared contract: only a pure
// text/refusal part array whose joined content trims empty is annotated.
// input_image, encrypted_content, input_file and any other non-text part is
// real output and must never be replaced.
return isWhitespaceOnlyTextPartArray(output);
}
return output === undefined || output === null;
}

/**
* Rewrite present-but-empty tool outputs to an explicit annotation. Synthetic
* missing-result placeholders are non-empty and pass through untouched. No-op unless
* the provider opts in (`annotateEmptyToolOutputs`).
*/
function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unknown {
if (!enabled || !isPlainObject(body) || !Array.isArray(body.input)) return body;
let changed = false;
const input = body.input.map(item => {
if (!isPlainObject(item) || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output")) return item;
if (!isToolOutputEmpty(item.output)) return item;
changed = true;
return { ...item, output: EMPTY_TOOL_OUTPUT_ANNOTATION };
});
return changed ? { ...body, input } : body;
}

/**
* Repair a forward-mode input array whose continuation context was lost. When the replay
* expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped
Expand Down Expand Up @@ -1705,6 +1736,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
if (forward || stateless) {
outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward);
}
if (provider.annotateEmptyToolOutputs === true) {
outBody = annotateEmptyResponsesToolOutputs(outBody, true);
}
if (provider.requiresAdjacentResponsesToolResults === true) {
outBody = normalizeResponsesToolResultAdjacency(outBody);
}
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,7 @@ const providerConfigSchema = z.object({
responsesPath: z.string().min(1).optional(),
statelessResponses: z.boolean().optional(),
requiresAdjacentResponsesToolResults: z.boolean().optional(),
annotateEmptyToolOutputs: z.boolean().optional(),
fastWire: fastWireSchema.nullable().optional(),
supportsServiceTier: z.boolean().optional(),
modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
Expand Down
6 changes: 6 additions & 0 deletions src/providers/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
...(entry.requiresAdjacentResponsesToolResults !== undefined
? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults }
: {}),
...(entry.annotateEmptyToolOutputs !== undefined
? { annotateEmptyToolOutputs: entry.annotateEmptyToolOutputs }
: {}),
...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}),
Expand Down Expand Up @@ -474,6 +477,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) {
prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults;
}
if (prov.annotateEmptyToolOutputs === undefined && seed.annotateEmptyToolOutputs !== undefined) {
prov.annotateEmptyToolOutputs = seed.annotateEmptyToolOutputs;
}
// Registry-only metadata (never seeded into saved config): backfill straight from
// the entry so an explicit user value stays distinguishable from the default.
if (prov.fastWire === undefined && entry.fastWire !== undefined) {
Expand Down
9 changes: 9 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ export interface ProviderRegistryEntry {
* to stay contiguous. This is seeded/backfilled like other fixed wire capabilities.
*/
requiresAdjacentResponsesToolResults?: boolean;
/**
* When enabled, tool results that are present but empty are annotated on the wire.
* Seeded/backfilled like other fixed wire capabilities.
*/
annotateEmptyToolOutputs?: boolean;
/**
* Registry default for the provider's `service_tier` support; see
* `OcxProviderConfig.supportsServiceTier`. Registry-only: backfilled (never
Expand Down Expand Up @@ -1682,6 +1687,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
// context splits a call from its result (#1292); parallel calls remain one
// reasoning-bearing assistant batch rather than being split per pair (#1477).
requiresAdjacentResponsesToolResults: true,
// DeepSeek exec tool results can be present-but-empty (a script that ran without
// calling text(...)); annotate them so routed models do not silently accept an
// empty result or re-issue the same call.
annotateEmptyToolOutputs: true,
/* [Decision Log]
- 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content.
- 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata.
Expand Down
4 changes: 4 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,10 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
&& registryEntry.requiresAdjacentResponsesToolResults !== undefined
? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults }
: {}),
...(provider.annotateEmptyToolOutputs === undefined
&& registryEntry.annotateEmptyToolOutputs !== undefined
? { annotateEmptyToolOutputs: registryEntry.annotateEmptyToolOutputs }
: {}),
...(provider.fastWire === undefined && registryEntry.fastWire !== undefined
? {
fastWire: cloneFastWire(registryEntry.fastWire),
Expand Down
3 changes: 3 additions & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
if (raw.responsesSnapshotRepair !== undefined && typeof raw.responsesSnapshotRepair !== "boolean") {
return `provider ${name} responsesSnapshotRepair must be a boolean`;
}
if (raw.annotateEmptyToolOutputs !== undefined && typeof raw.annotateEmptyToolOutputs !== "boolean") {
return `provider ${name} annotateEmptyToolOutputs must be a boolean`;
}
const defaultMaxOutputError = positiveIntegerConfigError(raw.defaultMaxOutputTokens, "defaultMaxOutputTokens");
if (defaultMaxOutputError) return `provider ${name} ${defaultMaxOutputError}`;
const maxOutputError = positiveIntegerRecordConfigError(raw.modelMaxOutputTokens, "modelMaxOutputTokens");
Expand Down
8 changes: 8 additions & 0 deletions src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ export interface OcxProviderConfig {
* preserved after it, and parallel calls stay together with the reasoning turn that produced them.
*/
requiresAdjacentResponsesToolResults?: boolean;
/**
* When enabled, a tool result that is present but empty (no usable text or content
* part) is rewritten to an explicit annotation before it reaches the upstream wire,
* so models do not silently accept an empty result or re-issue the same call.
* Non-empty results and missing-result placeholders stay byte-identical.
* Seeded true for DeepSeek; absent keeps legacy behavior for every other provider.
*/
annotateEmptyToolOutputs?: boolean;
/**
* Provider fallback for canonical Fast capability over an OpenAI `service_tier` wire.
* This pure tri-state feeds catalog publication, routing eligibility, compatibility
Expand Down
Loading
Loading