GigaChat: rehydrate nested tool arguments
This commit is contained in:
parent
302201526e
commit
eae28f7623
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user