GigaChat: rehydrate nested tool arguments

This commit is contained in:
Alexander Davydov 2026-03-20 11:59:16 +03:00
parent 302201526e
commit eae28f7623
2 changed files with 205 additions and 0 deletions

View File

@ -228,6 +228,94 @@ describe("createGigachatStreamFn tool calling", () => {
]);
});
it("rehydrates nested JSON-string tool arguments before dispatching them", async () => {
const interactivePayload = {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
},
],
};
const argumentPayload = JSON.stringify({
message: "Pick one",
interactive: JSON.stringify(interactivePayload),
});
request.mockResolvedValueOnce({
status: 200,
data: createSseStream([
`data: ${JSON.stringify({
choices: [{ delta: { function_call: { name: "message", arguments: argumentPayload } } }],
})}`,
"data: [DONE]",
]),
});
const streamFn = createGigachatStreamFn({
baseUrl: "https://gigachat.devices.sberbank.ru/api/v1",
authMode: "oauth",
});
const stream = await streamFn(
{ api: "gigachat", provider: "gigachat", id: "GigaChat-2-Max" } as never,
{
messages: [],
tools: [
{
name: "message",
description: "Send a message",
parameters: {
type: "object",
properties: {
message: { type: "string" },
interactive: {
type: "object",
properties: {
blocks: {
type: "array",
items: {
type: "object",
properties: {
type: { type: "string" },
buttons: {
type: "array",
items: {
type: "object",
properties: {
label: { type: "string" },
value: { type: "string" },
},
},
},
},
},
},
},
},
},
},
},
],
} as never,
{ apiKey: "token" } as never,
);
const event = await stream.result();
expect(event.stopReason).toBe("toolUse");
expect(event.content).toEqual([
expect.objectContaining({
type: "toolCall",
name: "message",
arguments: {
message: "Pick one",
interactive: interactivePayload,
},
}),
]);
});
it("parses a final SSE frame even when the stream closes without a trailing newline", async () => {
request.mockResolvedValueOnce({
status: 200,

View File

@ -309,6 +309,117 @@ function tryParseJsonObjectString(content: string): string | null {
}
}
function resolveSchemaType(schema: unknown): string | undefined {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
return undefined;
}
const type = (schema as Record<string, unknown>).type;
if (typeof type === "string") {
return type;
}
if (Array.isArray(type)) {
return type.find((value): value is string => typeof value === "string" && value !== "null");
}
return undefined;
}
function tryParseJsonValue(content: string): unknown {
try {
return JSON.parse(content);
} catch {
return undefined;
}
}
function rehydrateGigaChatArgumentValue(value: unknown, schema: unknown): unknown {
const schemaType = resolveSchemaType(schema);
const schemaObj =
schema && typeof schema === "object" && !Array.isArray(schema)
? (schema as Record<string, unknown>)
: undefined;
if (schemaType === "object" || (!schemaType && schemaObj?.properties)) {
let objectValue = value;
if (typeof objectValue === "string") {
const parsed = tryParseJsonValue(objectValue);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return value;
}
objectValue = parsed;
}
if (!objectValue || typeof objectValue !== "object" || Array.isArray(objectValue)) {
return objectValue;
}
const properties =
schemaObj?.properties &&
typeof schemaObj.properties === "object" &&
!Array.isArray(schemaObj.properties)
? (schemaObj.properties as Record<string, unknown>)
: undefined;
if (!properties) {
return objectValue;
}
const nextObject = { ...(objectValue as Record<string, unknown>) };
for (const [key, propertySchema] of Object.entries(properties)) {
if (key in nextObject) {
nextObject[key] = rehydrateGigaChatArgumentValue(nextObject[key], propertySchema);
}
}
return nextObject;
}
if (schemaType === "array") {
let arrayValue = value;
if (typeof arrayValue === "string") {
const parsed = tryParseJsonValue(arrayValue);
if (!Array.isArray(parsed)) {
return value;
}
arrayValue = parsed;
}
if (!Array.isArray(arrayValue)) {
return arrayValue;
}
const itemSchema = schemaObj?.items;
if (!itemSchema) {
return arrayValue;
}
return arrayValue.map((item) => rehydrateGigaChatArgumentValue(item, itemSchema));
}
return value;
}
function rehydrateGigaChatArguments(
args: Record<string, unknown>,
schema: unknown,
): Record<string, unknown> {
const schemaObj =
schema && typeof schema === "object" && !Array.isArray(schema)
? (schema as Record<string, unknown>)
: undefined;
const properties =
schemaObj?.properties &&
typeof schemaObj.properties === "object" &&
!Array.isArray(schemaObj.properties)
? (schemaObj.properties as Record<string, unknown>)
: undefined;
if (!properties) {
return args;
}
const nextArgs = { ...args };
for (const [key, propertySchema] of Object.entries(properties)) {
if (key in nextArgs) {
nextArgs[key] = rehydrateGigaChatArgumentValue(nextArgs[key], propertySchema);
}
}
return nextArgs;
}
/**
* Coerce tool result content to a JSON object string (gpt2giga compatibility).
* GigaChat expects tool results to be JSON objects. If the content is already
@ -608,6 +719,7 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn {
const functionsEnabled = disableFunctions !== "1" && disableFunctions !== "true";
const toolNameToGiga = new Map<string, string>();
const gigaToToolName = new Map<string, string>();
const gigaToolSchemas = new Map<string, unknown>();
// Build messages for GigaChat format
const messages: Message[] = [];
@ -717,6 +829,7 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn {
if (sanitizedName !== tool.name) {
log.debug(`GigaChat: sanitized function name "${tool.name}" → "${sanitizedName}"`);
}
gigaToolSchemas.set(sanitizedName, tool.parameters);
functions.push({
name: sanitizedName,
@ -902,6 +1015,10 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn {
const clientName =
gigaToToolName.get(resolvedFunctionCall.name) ??
mapToolNameFromGigaChat(resolvedFunctionCall.name);
parsedArgs = rehydrateGigaChatArguments(
parsedArgs,
gigaToolSchemas.get(resolvedFunctionCall.name),
);
accumulatedToolCalls.push({
type: "toolCall",
id: randomUUID(),