Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
43 changes: 43 additions & 0 deletions src/adapters/empty-tool-output-annotation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* 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);
}

/** Part types that carry wire text on either adapter (Chat uses `text`, Responses uses `input_text`/`output_text`). */
const TEXT_PART_TYPES = new Set(["text", "input_text", "output_text"]);

/**
* 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 (typeof part.type === "string" && TEXT_PART_TYPES.has(part.type) && 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 @@ -595,9 +596,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 @@ -784,7 +798,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 @@ -829,7 +843,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
38 changes: 38 additions & 0 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-com
import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat";
import { openaiResponsesUrl } from "./openai-responses-url";
import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai-web-search";
import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation";
import {
isXaiSchemaTarget,
normalizeXaiToolParameters,
Expand Down Expand Up @@ -821,6 +822,40 @@ 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);
}
// A missing or null `output` is not a present-but-empty result: it is an
// incomplete payload. Leave it untouched so the upstream contract fails
// closed, and the orphan repair can surface it honestly instead of claiming
// the tool ran with no output.
return false;
}

/**
* 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 @@ -2008,6 +2043,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
// pair from its own storage either, so it needs the same repair the forward
// backend gets — dropping previous_response_id is not much use if the body that
// reaches the wire is unparseable.
if (provider.annotateEmptyToolOutputs === true) {
outBody = annotateEmptyResponsesToolOutputs(outBody, true);
}
if (forward || stateless) {
outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward);
}
Expand Down
6 changes: 5 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,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 Expand Up @@ -3161,7 +3162,10 @@ export function applyProxyEnv(config: OcxConfig): void {
// malformed values with a privacy-safe warning instead: they cannot express a routing
// intent, and refusing to start is a worse answer than starting without them.
const rawProxy = config.proxy;
const proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined;
// Trim before resolution so a whitespace-padded environment reference (e.g.
// " ${VAR} ") still resolves, then trim the resolved value so a whitespace-only
// env value falls back to direct egress instead of leaking into HTTP(S)_PROXY.
const proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy.trim())?.trim() : undefined;
if (!proxy) {
if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy");
return;
Expand Down
11 changes: 11 additions & 0 deletions src/config/provider-validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
import { redactSecretString } from "../lib/redact";
import { modelRecordValue } from "../reasoning-effort";
import {
isWirePinnedModel,
Expand Down Expand Up @@ -123,6 +124,16 @@ export function booleanRecordConfigError(value: unknown, field: string): string
return null;
}

/** Validate the management DTO boundary for the opt-in empty-tool-output annotation. */
export function providerEmptyToolOutputConfigError(name: string, provider: unknown): string | null {
const raw = provider as Record<string, unknown> | null | undefined;
const value = raw === null || raw === undefined ? undefined : raw.annotateEmptyToolOutputs;
if (value !== undefined && typeof value !== "boolean") {
return `provider ${JSON.stringify(redactSecretString(name))} annotateEmptyToolOutputs must be a boolean`;
}
return null;
}

export function reasoningSummaryDeliveryRecordConfigError(
value: unknown,
supportsReasoningSummaries: unknown,
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 @@ -501,6 +504,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
13 changes: 11 additions & 2 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 @@ -1784,6 +1789,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 Expand Up @@ -2564,8 +2573,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
adapter: "ollama-native",
authKind: "key",
dashboardUrl: "https://ollama.com/settings/keys",
// Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15.
models: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"],
// Live IDs verified 2026-07-10; qwen3-coder:480b retired 2026-07-15 and was removed.
models: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"],
defaultModel: "glm-5.3",
// Owner-audited exact outage fallback: these current Ollama Cloud GLM-5.3 rows have
// 1,048,576-token context windows. Live discovery and successful /api/show enrichment keep
Expand Down
4 changes: 4 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,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
Loading
Loading