mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
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:
committed by
GitHub
parent
0cbdc95023
commit
b13c3cd202
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
86
tests/unit/translator-responses-cache-usage.test.ts
Normal file
86
tests/unit/translator-responses-cache-usage.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user