mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 04:42:10 +03:00
* fix(grok-cli): sanitize function_call_output before Grok Build dispatch (#7611) Grok Build cli-chat-proxy rejects Responses bodies when tool-result outputs contain incomplete \u escapes or other malformed JSON text. Sanitize function_call_output.output values in GrokCliExecutor so large agent tool transcripts no longer fail intermittently with 400 body-parse errors. * fix(grok-cli): type test credentials instead of casting through any (#7611) tests/ has no-explicit-any as an ESLint error; the 3 `{ accessToken: "tok" } as any` casts passed to transformRequest() were the proven, non-drift cause of this PR's own "No new ESLint warnings" CI failure. Replace them with a single properly-typed ProviderCredentials literal (all fields on that type are optional, so no cast is needed). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- **grok-cli:** sanitize Responses `function_call_output.output` values before dispatch so incomplete `\u` escapes / malformed tool results no longer cause intermittent 400 body-parse failures on cli-chat-proxy (#7611).
|
||||
@@ -33,6 +33,67 @@ const GROK_BUILD_UNSUPPORTED_PARAMS = [
|
||||
"reasoning_effort",
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Grok Build's cli-chat-proxy is stricter about Responses `function_call_output.output`
|
||||
* than OpenAI's Responses API. Agent tool results can contain truncated / incomplete
|
||||
* JSON strings or invalid `\uXXXX` escapes, which fail upstream with:
|
||||
* Failed to parse the request body as JSON: input[N].output: unexpected end of hex escape
|
||||
*
|
||||
* Normalize outputs to valid JSON text (or plain text) before dispatch (#7611).
|
||||
*/
|
||||
function sanitizeGrokBuildFunctionCallOutput(output: unknown): string {
|
||||
if (output == null) return "";
|
||||
if (typeof output === "string") {
|
||||
const value = output;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value));
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
// Drop incomplete \u escapes (0-3 hex digits) that break strict JSON parsers.
|
||||
const repaired = value.replace(/\\u([0-9A-Fa-f]{0,3})(?![0-9A-Fa-f])/g, "");
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(repaired));
|
||||
} catch {
|
||||
return repaired.replace(/[\uD800-\uDFFF]/g, "\uFFFD");
|
||||
}
|
||||
}
|
||||
if (Array.isArray(output)) {
|
||||
const textParts = output
|
||||
.map((part) => {
|
||||
if (part && typeof part === "object") {
|
||||
const rec = part as Record<string, unknown>;
|
||||
if (typeof rec.text === "string") return rec.text;
|
||||
}
|
||||
return typeof part === "string" ? part : JSON.stringify(part);
|
||||
})
|
||||
.join("\n");
|
||||
return sanitizeGrokBuildFunctionCallOutput(textParts);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(output);
|
||||
} catch {
|
||||
return String(output);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeGrokBuildResponsesBody(body: Record<string, unknown>): Record<string, unknown> {
|
||||
const input = body.input;
|
||||
if (!Array.isArray(input)) return body;
|
||||
let changed = false;
|
||||
const nextInput = input.map((item) => {
|
||||
if (!item || typeof item !== "object") return item;
|
||||
const rec = item as Record<string, unknown>;
|
||||
if (rec.type !== "function_call_output") return item;
|
||||
const sanitized = sanitizeGrokBuildFunctionCallOutput(rec.output);
|
||||
if (sanitized === rec.output) return item;
|
||||
changed = true;
|
||||
return { ...rec, output: sanitized };
|
||||
});
|
||||
return changed ? { ...body, input: nextInput } : body;
|
||||
}
|
||||
|
||||
type GrokBuildRefreshResult = Partial<ProviderCredentials> | null | undefined;
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
@@ -247,6 +308,7 @@ export class GrokCliExecutor extends BaseExecutor {
|
||||
transformed.tools = transformed.tools.slice(0, GROK_BUILD_MAX_TOOLS);
|
||||
}
|
||||
|
||||
return transformed;
|
||||
// Repair tool-result payloads that would fail Grok's strict JSON body parser (#7611).
|
||||
return sanitizeGrokBuildResponsesBody(transformed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { GrokCliExecutor } from "../../open-sse/executors/grok-cli.ts";
|
||||
import type { ProviderCredentials } from "../../open-sse/executors/base.ts";
|
||||
|
||||
const testCredentials: ProviderCredentials = { accessToken: "tok" };
|
||||
|
||||
test("#7611: GrokCliExecutor sanitizes incomplete hex escapes in function_call_output", () => {
|
||||
const executor = new GrokCliExecutor();
|
||||
const body = {
|
||||
model: "grok-4.5",
|
||||
input: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_1",
|
||||
name: "read_file",
|
||||
arguments: "{}",
|
||||
},
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_1",
|
||||
// incomplete \u escape — the production failure mode
|
||||
output: '{"path":"foo","snippet":"bad \\u12"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const transformed = executor.transformRequest("grok-4.5", body, true, testCredentials) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const input = transformed.input as Array<Record<string, unknown>>;
|
||||
const outputItem = input.find((item) => item.type === "function_call_output");
|
||||
assert.ok(outputItem, "function_call_output item should remain");
|
||||
assert.equal(typeof outputItem.output, "string");
|
||||
// Must be re-parseable as a JSON string payload after sanitization.
|
||||
assert.doesNotThrow(() => JSON.parse(JSON.stringify({ output: outputItem.output })));
|
||||
assert.doesNotMatch(String(outputItem.output), /\\u12(?![0-9A-Fa-f])/);
|
||||
});
|
||||
|
||||
test("#7611: valid JSON tool outputs are re-serialized cleanly", () => {
|
||||
const executor = new GrokCliExecutor();
|
||||
const payload = { ok: true, data: ["a", "b"], note: "café" };
|
||||
const body = {
|
||||
model: "grok-4.5",
|
||||
input: [
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_2",
|
||||
output: JSON.stringify(payload),
|
||||
},
|
||||
],
|
||||
};
|
||||
const transformed = executor.transformRequest("grok-4.5", body, false, testCredentials) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const input = transformed.input as Array<Record<string, unknown>>;
|
||||
const outputItem = input.find((item) => item.type === "function_call_output");
|
||||
assert.deepEqual(JSON.parse(String(outputItem?.output)), payload);
|
||||
});
|
||||
|
||||
test("#7611: array content parts are flattened to text", () => {
|
||||
const executor = new GrokCliExecutor();
|
||||
const body = {
|
||||
model: "grok-4.5",
|
||||
input: [
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_3",
|
||||
output: [
|
||||
{ type: "input_text", text: "hello" },
|
||||
{ type: "input_text", text: "world" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const transformed = executor.transformRequest("grok-4.5", body, false, testCredentials) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const input = transformed.input as Array<Record<string, unknown>>;
|
||||
const outputItem = input.find((item) => item.type === "function_call_output");
|
||||
assert.equal(typeof outputItem?.output, "string");
|
||||
assert.match(String(outputItem?.output), /hello/);
|
||||
assert.match(String(outputItem?.output), /world/);
|
||||
});
|
||||
Reference in New Issue
Block a user