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
2 changes: 1 addition & 1 deletion open-sse/handlers/chatCore/upstreamBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
79 changes: 71 additions & 8 deletions open-sse/translator/response/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand All @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -1046,10 +1098,20 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
state.toolCallIndex++;
state.currentToolCallArgsBuffer = ""; // reset for next tool call
state.currentToolCallId = null;

// 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);
const needsNormalization = state.currentToolCallNeedsNormalization === true;
state.currentToolCallNeedsNormalization = false;
state.currentToolCallName = "";

// 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) {
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 12 additions & 3 deletions open-sse/translator/response/openai-responses/pureHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
23 changes: 18 additions & 5 deletions tests/unit/chatcore-upstream-body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
26 changes: 16 additions & 10 deletions tests/unit/repro-6951.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
Loading