fix(translator): normalize streamed optional tool arguments (#9423)

* fix: preserve Codex cache usage for Claude suggestions

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: normalize streamed optional tool arguments

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Kittisak Tangsiri <kittisak@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Kittisak Tangsiri
2026-08-11 14:35:30 +07:00
committed by GitHub
parent 0cbdc95023
commit b13c3cd202
8 changed files with 408 additions and 42 deletions

View File

@@ -99,7 +99,7 @@ async function injectPromptCacheKey(
providerSupportsCaching(provider, undefined, connectionCacheOverride) &&
!bodyToSend.prompt_cache_key &&
Array.isArray(bodyToSend.messages) &&
!["nvidia", "codex", "xai"].includes(provider)
!["nvidia", "xai"].includes(provider)
) {
const { generatePromptCacheKey } = await import("@/lib/promptCache");
const cacheKey = generatePromptCacheKey(bodyToSend.messages);

View File

@@ -846,6 +846,48 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
function openaiResponsesToOpenAIResponseStream(chunk, state) {
if (!chunk) {
if (
state.currentToolCallNeedsNormalization &&
state.currentToolCallArgsBuffer &&
state.currentToolCallName
) {
const toolSchema = state.toolSchemas?.get(state.currentToolCallName);
const argsToEmit = stripEmptyOptionalToolArgs(
state.currentToolCallArgsBuffer,
state.currentToolCallName,
toolSchema
);
const argsStr =
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {});
state.currentToolCallArgsBuffer = "";
state.currentToolCallNeedsNormalization = false;
state.finishReasonSent = true;
state.finishReason = "tool_calls";
const common = {
id: state.chatId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "gpt-4",
};
return [
{
...common,
choices: [
{
index: 0,
delta: {
tool_calls: [{ index: state.toolCallIndex, function: { arguments: argsStr } }],
},
finish_reason: null,
},
],
},
{
...common,
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
},
];
}
// Flush: send final chunk with finish_reason
if (!state.finishReasonSent && state.started) {
state.finishReasonSent = true;
@@ -931,6 +973,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
const toolName = normalizeToolName(item.name);
state.currentToolName = toolName; // track for schema lookup at done time
state.currentToolCallName = toolName;
state.currentToolCallNeedsNormalization = toolName === "Agent";
if (!toolName) {
// Some Responses providers briefly emit placeholder/empty tool names.
// Defer emission until output_item.done in case the final name is populated there.
@@ -974,7 +1018,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
if (!argsDelta) return null;
state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta;
if (state.currentToolCallDeferred) return null;
if (state.currentToolCallDeferred || state.currentToolCallNeedsNormalization) return null;
// #9168: buffer arguments until output_item.done for schema-aware null normalization
// Previously emitted raw null values for optional enum fields (e.g. isolation: null).
@@ -991,6 +1035,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
const callId = item.call_id || state.currentToolCallId || fallbackToolCallId();
const toolName = normalizeToolName(item.name);
const toolSchema = state.toolSchemas?.get(toolName);
const shouldNormalizeArguments = toolName === "Agent";
state.currentToolCallNeedsNormalization = shouldNormalizeArguments;
// Track this call_id so response.completed doesn't synthesize a duplicate
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
@@ -1007,7 +1053,13 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
state.toolCallIndex++;
const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName, toolSchema);
const terminalArguments =
typeof item.arguments === "string"
? item.arguments.length > 0
? item.arguments
: buffered
: (item.arguments ?? buffered);
const argsToEmit = stripEmptyOptionalToolArgs(terminalArguments, toolName, toolSchema);
const argsStr =
argsToEmit != null
@@ -1046,10 +1098,20 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
state.toolCallIndex++;
state.currentToolCallArgsBuffer = ""; // reset for next tool call
state.currentToolCallId = null;
const needsNormalization = state.currentToolCallNeedsNormalization === true;
state.currentToolCallNeedsNormalization = false;
state.currentToolCallName = "";
// Only emit if arguments exist in the done event AND they weren't already streamed via deltas
if (item.arguments != null && !buffered) {
const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName, toolSchema);
// Nullable omission sentinels must be normalized before any argument bytes reach the client.
// Other tool calls retain immediate argument streaming.
if ((needsNormalization || !buffered) && (item.arguments != null || buffered)) {
const terminalArguments =
typeof item.arguments === "string"
? item.arguments.length > 0
? item.arguments
: buffered
: (item.arguments ?? buffered);
const argsToEmit = stripEmptyOptionalToolArgs(terminalArguments, toolName, toolSchema);
const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit);
if (argsStr) {
@@ -1127,8 +1189,9 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
responseUsage.reasoning_tokens ||
0;
// prompt_tokens = input_tokens + cache_read + cache_creation (all prompt-side tokens)
const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
const promptTokens =
inputTokens +
("cache_read_input_tokens" in responseUsage ? cacheReadTokens + cacheCreationTokens : 0);
state.usage = {
prompt_tokens: promptTokens,

View File

@@ -60,8 +60,17 @@ function isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) {
// no-default optional enum properties to accept `null`, meaning "omitted" (OpenAI's own
// nullable-union idiom for Responses-API strict mode). Drop the key when the model
// follows that idiom for a non-required, schema-declared property.
function isDroppableNullEntry(entry, propSchema, required, key) {
return entry === null && propSchema != null && !required.has(key);
function isDroppableNullEntry(entry, propSchema, required, key, toolName) {
if (entry !== null) return false;
if (toolName === "Agent") return true;
if (propSchema == null) return false;
const omissionSentinel =
typeof propSchema === "object" &&
Array.isArray(propSchema.enum) &&
propSchema.enum.includes(null) &&
typeof propSchema.description === "string" &&
propSchema.description.includes("null = omit this parameter");
return !required.has(key) || omissionSentinel;
}
function stripEmptyOptionalToolArgsObject(value, toolName, schema) {
@@ -75,7 +84,7 @@ function stripEmptyOptionalToolArgsObject(value, toolName, schema) {
if (
matchesSchemaDefault(propSchema, entry) ||
isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) ||
isDroppableNullEntry(entry, propSchema, required, key)
isDroppableNullEntry(entry, propSchema, required, key, toolName)
) {
delete cleaned[key];
}

View File

@@ -20,9 +20,11 @@ export function extractToolSchemaMap(body: unknown): Map<string, JsonRecord> | n
const item = asRecord(tool);
if (!item) continue;
const fn = asRecord(item.function);
const name = (typeof fn?.name === "string" ? fn.name : typeof item.name === "string" ? item.name : "").trim();
const name = (
typeof fn?.name === "string" ? fn.name : typeof item.name === "string" ? item.name : ""
).trim();
if (!name) continue;
const schema = asRecord(fn?.parameters ?? item.parameters);
const schema = asRecord(fn?.parameters ?? item.parameters ?? item.input_schema);
if (schema) map.set(name, schema);
}
return map.size > 0 ? map : null;

View File

@@ -229,18 +229,31 @@ test("preserves the full tool list when within the grok-cli limit", async () =>
targetFormat: "claude",
credentials: null,
});
assert.ok(Array.isArray(out.tools));
assert.equal(out.tools.length, 150);
});
test("never injects prompt_cache_key for an excluded provider (codex)", async () => {
const out = await prepareUpstreamBody({
translatedBody: { model: "gpt-5-codex", messages: [{ role: "user", content: "hi" }] },
test("injects a stable prompt_cache_key for Codex automatic prefix caching", async () => {
const request = {
model: "gpt-5-codex",
messages: [
{ role: "system", content: "stable coding instructions" },
{ role: "user", content: "fix this" },
],
};
const opts = {
translatedBody: request,
modelToCall: "gpt-5-codex",
provider: "codex",
targetFormat: "openai",
credentials: null,
});
assert.equal(out.prompt_cache_key, undefined);
};
const first = await prepareUpstreamBody(opts);
const second = await prepareUpstreamBody(opts);
assert.match(String(first.prompt_cache_key), /^omni-[0-9a-f]{32}$/);
assert.equal(second.prompt_cache_key, first.prompt_cache_key);
});
test("never injects prompt_cache_key when the target format is not OpenAI", async () => {

View File

@@ -11,15 +11,12 @@ import assert from "node:assert/strict";
// and the end-to-end schema threading from the request's `tools[]` into the streaming
// call sites in `openai-responses.ts` (response.output_item.done handling).
const { stripEmptyOptionalToolArgs } = await import(
"../../open-sse/translator/response/openai-responses/pureHelpers.ts"
);
const { extractToolSchemaMap } = await import(
"../../open-sse/translator/response/openai-responses/toolSchemas.ts"
);
const { openaiResponsesToOpenAIResponse } = await import(
"../../open-sse/translator/response/openai-responses.ts"
);
const { stripEmptyOptionalToolArgs } =
await import("../../open-sse/translator/response/openai-responses/pureHelpers.ts");
const { extractToolSchemaMap } =
await import("../../open-sse/translator/response/openai-responses/toolSchemas.ts");
const { openaiResponsesToOpenAIResponse } =
await import("../../open-sse/translator/response/openai-responses.ts");
const AGENT_SCHEMA = {
type: "object",
@@ -101,13 +98,22 @@ test("6951: extractToolSchemaMap builds a name->schema map from Chat Completions
assert.equal(extractToolSchemaMap({}), null);
});
test("6951: extractToolSchemaMap accepts Claude input_schema tools", () => {
const body = { tools: [{ name: "Agent", input_schema: AGENT_SCHEMA }] };
const map = extractToolSchemaMap(body);
assert.equal(map?.get("Agent"), AGENT_SCHEMA);
});
test("6951: RED->GREEN — schema threaded end-to-end strips the default-valued isolation arg", () => {
// Simulates createSSEStream's TranslateState carrying `toolSchemas` extracted from the
// request body, as wired in open-sse/utils/stream.ts.
const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA]]) };
openaiResponsesToOpenAIResponse(
{ type: "response.output_item.added", item: { type: "function_call", call_id: "call_1", name: "Agent" } },
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "Agent" },
},
state
);
const done = openaiResponsesToOpenAIResponse(

View File

@@ -11,15 +11,12 @@ import assert from "node:assert/strict";
// own documented nullable-union idiom for this exact strict-mode limitation), and
// response-side drops the key when the model emits `null` for a non-required property.
const { injectOptionalEnumOmissionSentinel, injectOptionalEnumOmissionForTools } = await import(
"../../open-sse/translator/helpers/schemaCoercion.ts"
);
const { stripEmptyOptionalToolArgs } = await import(
"../../open-sse/translator/response/openai-responses/pureHelpers.ts"
);
const { openaiResponsesToOpenAIResponse } = await import(
"../../open-sse/translator/response/openai-responses.ts"
);
const { injectOptionalEnumOmissionSentinel, injectOptionalEnumOmissionForTools } =
await import("../../open-sse/translator/helpers/schemaCoercion.ts");
const { stripEmptyOptionalToolArgs } =
await import("../../open-sse/translator/response/openai-responses/pureHelpers.ts");
const { openaiResponsesToOpenAIResponse } =
await import("../../open-sse/translator/response/openai-responses.ts");
const { translateRequest } = await import("../../open-sse/translator/index.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
@@ -60,7 +57,9 @@ test("7023: injectOptionalEnumOmissionSentinel leaves a required enum property u
test("7023: injectOptionalEnumOmissionSentinel leaves an enum property with a default untouched", () => {
const schema = {
type: "object",
properties: { isolation: { type: "string", enum: ["worktree", "remote"], default: "worktree" } },
properties: {
isolation: { type: "string", enum: ["worktree", "remote"], default: "worktree" },
},
required: [],
};
const result = injectOptionalEnumOmissionSentinel(schema);
@@ -124,7 +123,9 @@ test("7023: translateRequest applies the injection only for targetFormat OPENAI_
"gpt-5.1-codex",
JSON.parse(JSON.stringify(body))
);
const responsesTool = toResponses.tools.find((t) => t.name === "Agent" || t?.function?.name === "Agent");
const responsesTool = toResponses.tools.find(
(t) => t.name === "Agent" || t?.function?.name === "Agent"
);
const responsesParams = responsesTool.parameters ?? responsesTool.function?.parameters;
assert.ok(responsesParams.properties.isolation.enum.includes(null));
@@ -143,7 +144,16 @@ const AGENT_SCHEMA_OPTIONAL = {
type: "object",
properties: {
description: { type: "string" },
isolation: { type: ["string", "null"], enum: ["worktree", "remote", null] },
model: {
type: ["string", "null"],
enum: ["sonnet", "opus", "haiku", "fable", null],
description: "Model override (null = omit this parameter)",
},
isolation: {
type: ["string", "null"],
enum: ["worktree", "remote", null],
description: "Isolation mode (null = omit this parameter)",
},
},
required: ["description"],
};
@@ -166,11 +176,38 @@ test("7023: stripEmptyOptionalToolArgs preserves null for a required property (n
assert.equal(Object.prototype.hasOwnProperty.call(cleaned, "note"), true);
});
test("7023: stripEmptyOptionalToolArgs drops the omission sentinel after strict mode marks it required", () => {
const schema = {
type: "object",
properties: {
isolation: {
type: ["string", "null"],
enum: ["worktree", "remote", null],
description: "Isolation mode (null = omit this parameter)",
},
},
required: ["isolation"],
};
const cleaned = JSON.parse(
stripEmptyOptionalToolArgs(JSON.stringify({ isolation: null }), "Agent", schema)
);
assert.equal(Object.prototype.hasOwnProperty.call(cleaned, "isolation"), false);
});
test("7023: Agent null fields drop even when the strict schema snapshot is unavailable", () => {
const raw = JSON.stringify({ description: "schema unavailable", model: null, isolation: null });
const cleaned = JSON.parse(stripEmptyOptionalToolArgs(raw, "Agent", null));
assert.deepEqual(cleaned, { description: "schema unavailable" });
});
test("7023: acceptance — codex Agent call emits isolation:null (post-injection idiom) -> client-visible call has no isolation key", () => {
const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) };
openaiResponsesToOpenAIResponse(
{ type: "response.output_item.added", item: { type: "function_call", call_id: "call_1", name: "Agent" } },
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "Agent" },
},
state
);
const done = openaiResponsesToOpenAIResponse(
@@ -191,11 +228,161 @@ test("7023: acceptance — codex Agent call emits isolation:null (post-injection
assert.equal(args.description, "no isolation intended");
});
test("7023: streamed Agent arguments are buffered until the null omission sentinel is stripped", () => {
const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) };
openaiResponsesToOpenAIResponse(
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "Agent" },
},
state
);
const raw = JSON.stringify({
description: "no isolation intended",
model: null,
isolation: null,
});
const firstDelta = openaiResponsesToOpenAIResponse(
{ type: "response.function_call_arguments.delta", delta: raw.slice(0, 35) },
state
);
const secondDelta = openaiResponsesToOpenAIResponse(
{ type: "response.function_call_arguments.delta", delta: raw.slice(35) },
state
);
const done = openaiResponsesToOpenAIResponse(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "Agent", arguments: raw },
},
state
);
assert.equal(firstDelta, null);
assert.equal(secondDelta, null);
const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments);
assert.equal(Object.prototype.hasOwnProperty.call(args, "model"), false);
assert.equal(Object.prototype.hasOwnProperty.call(args, "isolation"), false);
assert.equal(args.description, "no isolation intended");
});
test("7023: buffered fragments are normalized when done omits its argument snapshot", () => {
const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) };
const raw = JSON.stringify({ description: "fragmented", model: null, isolation: null });
openaiResponsesToOpenAIResponse(
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_2", name: "Agent" },
},
state
);
openaiResponsesToOpenAIResponse(
{ type: "response.function_call_arguments.delta", delta: raw.slice(0, 25) },
state
);
openaiResponsesToOpenAIResponse(
{ type: "response.function_call_arguments.delta", delta: raw.slice(25) },
state
);
const done = openaiResponsesToOpenAIResponse(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_2", name: "Agent" },
},
state
);
const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments);
assert.deepEqual(args, { description: "fragmented" });
});
test("7023: stream flush emits normalized arguments and terminal finish reason", () => {
const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) };
const raw = JSON.stringify({ description: "flush", model: null, isolation: null });
openaiResponsesToOpenAIResponse(
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_flush", name: "Agent" },
},
state
);
openaiResponsesToOpenAIResponse(
{ type: "response.function_call_arguments.delta", delta: raw },
state
);
const flushed = openaiResponsesToOpenAIResponse(null, state);
assert.equal(flushed.length, 2);
const args = JSON.parse(flushed[0].choices[0].delta.tool_calls[0].function.arguments);
assert.deepEqual(args, { description: "flush" });
assert.equal(flushed[1].choices[0].finish_reason, "tool_calls");
});
test("7023: object-valued terminal arguments retain explicit isolation", () => {
const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) };
openaiResponsesToOpenAIResponse(
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_object", name: "Agent" },
},
state
);
const done = openaiResponsesToOpenAIResponse(
{
type: "response.output_item.done",
item: {
type: "function_call",
call_id: "call_object",
name: "Agent",
arguments: { description: "explicit", model: null, isolation: "worktree" },
},
},
state
);
const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments);
assert.deepEqual(args, { description: "explicit", isolation: "worktree" });
});
test("7023: deferred Agent name normalizes buffered null sentinels", () => {
const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) };
const raw = JSON.stringify({ description: "deferred", model: null, isolation: null });
openaiResponsesToOpenAIResponse(
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_deferred", name: "" },
},
state
);
openaiResponsesToOpenAIResponse(
{ type: "response.function_call_arguments.delta", delta: raw },
state
);
const done = openaiResponsesToOpenAIResponse(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_deferred", name: "Agent" },
},
state
);
const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments);
assert.deepEqual(args, { description: "deferred" });
});
test("7023: negative — a legitimate isolation:'worktree' value is preserved unchanged", () => {
const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) };
openaiResponsesToOpenAIResponse(
{ type: "response.output_item.added", item: { type: "function_call", call_id: "call_2", name: "Agent" } },
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_2", name: "Agent" },
},
state
);
const done = openaiResponsesToOpenAIResponse(

View File

@@ -0,0 +1,86 @@
import test from "node:test";
import assert from "node:assert/strict";
const { openaiResponsesToOpenAIResponse } =
await import("../../open-sse/translator/response/openai-responses.ts");
const { openaiToClaudeResponse } =
await import("../../open-sse/translator/response/openai-to-claude.ts");
function createResponsesState() {
return {
started: false,
finishReasonSent: false,
completedOutputItems: [],
funcArgsBuf: {},
funcNames: {},
funcCallIds: {},
funcArgsDone: {},
funcItemAdded: {},
funcItemDone: {},
};
}
function createClaudeState() {
return {
toolCalls: new Map(),
_pendingXmlToolCalls: [],
_xmlInvokeBuffer: "",
};
}
test("Responses cache usage survives the OpenAI-to-Claude streaming chain", () => {
const openaiChunk = openaiResponsesToOpenAIResponse(
{
type: "response.completed",
response: {
id: "resp-cache-hit",
model: "gpt-5.6-sol",
output: [],
usage: {
input_tokens: 21_023,
input_tokens_details: { cached_tokens: 20_224 },
output_tokens: 5,
},
},
},
createResponsesState()
);
assert.equal(openaiChunk.usage.prompt_tokens, 21_023);
assert.equal(openaiChunk.usage.prompt_tokens_details.cached_tokens, 20_224);
assert.equal(openaiChunk.usage.total_tokens, 21_028);
const events = openaiToClaudeResponse(openaiChunk, createClaudeState());
const messageDelta = events.find((event) => event.type === "message_delta");
assert.deepEqual(messageDelta.usage, {
input_tokens: 799,
output_tokens: 5,
cache_read_input_tokens: 20_224,
});
});
test("Anthropic-style Responses usage still adds cache tokens to total prompt tokens", () => {
const openaiChunk = openaiResponsesToOpenAIResponse(
{
type: "response.completed",
response: {
id: "resp-anthropic-usage",
model: "claude-test",
output: [],
usage: {
input_tokens: 799,
cache_read_input_tokens: 20_224,
cache_creation_input_tokens: 100,
output_tokens: 5,
},
},
},
createResponsesState()
);
assert.equal(openaiChunk.usage.prompt_tokens, 21_123);
assert.equal(openaiChunk.usage.prompt_tokens_details.cached_tokens, 20_224);
assert.equal(openaiChunk.usage.prompt_tokens_details.cache_creation_tokens, 100);
assert.equal(openaiChunk.usage.total_tokens, 21_128);
});