@ai-sdk/google
Version:
The **[Google Generative AI provider](https://ai-sdk.dev/providers/ai-sdk-providers/google-generative-ai)** for the [AI SDK](https://ai-sdk.dev/docs) contains language model support for the [Google Generative AI](https://ai.google/discover/generativeai/)
337 lines (293 loc) • 10.1 kB
text/typescript
export type PartialArg = {
jsonPath: string;
stringValue?: string | null;
numberValue?: number | null;
boolValue?: boolean | null;
nullValue?: unknown;
willContinue?: boolean | null;
};
type PathSegment = string | number;
type StackEntry = {
segment: PathSegment;
isArray: boolean;
childCount: number;
};
/**
* Incrementally builds a JSON object from Google's streaming `partialArgs`
* chunks emitted during tool-call function calling. Tracks both the structured
* object and a running JSON text representation so callers can emit text deltas
* that, when concatenated, form valid nested JSON matching JSON.stringify output.
*
* Input: [{jsonPath:"$.location",stringValue:"Boston"}]
* Output: '{"location":"Boston"', then finalize() → closingDelta='}'
*/
export class GoogleJSONAccumulator {
private accumulatedArgs: Record<string, unknown> = {};
private jsonText = '';
/**
* Stack representing the currently "open" containers in the JSON output.
* Entry 0 is always the root `{` object once the first value is written.
*/
private pathStack: StackEntry[] = [];
/**
* Whether a string value is currently "open" (willContinue was true),
* meaning the closing quote has not yet been emitted.
*/
private stringOpen = false;
/**
* Input: [{jsonPath:"$.brightness",numberValue:50}]
* Output: { currentJSON:{brightness:50}, textDelta:'{"brightness":50' }
*/
processPartialArgs(partialArgs: PartialArg[]): {
currentJSON: Record<string, unknown>;
textDelta: string;
} {
let delta = '';
for (const arg of partialArgs) {
const rawPath = arg.jsonPath.replace(/^\$\./, '');
if (!rawPath) continue;
const segments = parsePath(rawPath);
const existingValue = getNestedValue(this.accumulatedArgs, segments);
const isStringContinuation =
arg.stringValue != null && existingValue !== undefined;
if (isStringContinuation) {
const escaped = JSON.stringify(arg.stringValue).slice(1, -1);
setNestedValue(
this.accumulatedArgs,
segments,
(existingValue as string) + arg.stringValue,
);
delta += escaped;
continue;
}
const resolved = resolvePartialArgValue(arg);
if (resolved == null) continue;
setNestedValue(this.accumulatedArgs, segments, resolved.value);
delta += this.emitNavigationTo(segments, arg, resolved.json);
}
this.jsonText += delta;
return {
currentJSON: this.accumulatedArgs,
textDelta: delta,
};
}
/**
* Input: jsonText='{"brightness":50', accumulatedArgs={brightness:50}
* Output: { finalJSON:'{"brightness":50}', closingDelta:'}' }
*/
finalize(): { finalJSON: string; closingDelta: string } {
const finalArgs = JSON.stringify(this.accumulatedArgs);
const closingDelta = finalArgs.slice(this.jsonText.length);
return { finalJSON: finalArgs, closingDelta };
}
/**
* Input: pathStack=[] (first call) or pathStack=[root,...] (subsequent calls)
* Output: '{' (first call) or '' (subsequent calls)
*/
private ensureRoot(): string {
if (this.pathStack.length === 0) {
this.pathStack.push({ segment: '', isArray: false, childCount: 0 });
return '{';
}
return '';
}
/**
* Emits the JSON text fragment needed to navigate from the current open
* path to the new leaf at `targetSegments`, then writes the value.
*
* Input: targetSegments=["recipe","name"], arg={jsonPath:"$.recipe.name",stringValue:"Lasagna"}, valueJson='"Lasagna"'
* Output: '{"recipe":{"name":"Lasagna"'
*/
private emitNavigationTo(
targetSegments: PathSegment[],
arg: PartialArg,
valueJson: string,
): string {
let fragment = '';
if (this.stringOpen) {
fragment += '"';
this.stringOpen = false;
}
fragment += this.ensureRoot();
const targetContainerSegments = targetSegments.slice(0, -1);
const leafSegment = targetSegments[targetSegments.length - 1];
const commonDepth = this.findCommonStackDepth(targetContainerSegments);
fragment += this.closeDownTo(commonDepth);
fragment += this.openDownTo(targetContainerSegments, leafSegment);
fragment += this.emitLeaf(leafSegment, arg, valueJson);
return fragment;
}
/**
* Returns the stack depth to preserve when navigating to a new target
* container path. Always >= 1 (the root is never popped).
*
* Input: stack=[root,"recipe","ingredients",0], target=["recipe","ingredients",1]
* Output: 3 (keep root+"recipe"+"ingredients")
*/
private findCommonStackDepth(targetContainer: PathSegment[]): number {
const maxDepth = Math.min(
this.pathStack.length - 1,
targetContainer.length,
);
let common = 0;
for (let i = 0; i < maxDepth; i++) {
if (this.pathStack[i + 1].segment === targetContainer[i]) {
common++;
} else {
break;
}
}
return common + 1;
}
/**
* Closes containers from the current stack depth back down to `targetDepth`.
*
* Input: this.pathStack=[root,"recipe","ingredients",0], targetDepth=3
* Output: '}'
*/
private closeDownTo(targetDepth: number): string {
let fragment = '';
while (this.pathStack.length > targetDepth) {
const entry = this.pathStack.pop()!;
fragment += entry.isArray ? ']' : '}';
}
return fragment;
}
/**
* Opens containers from the current stack depth down to the full target
* container path, emitting opening `{`, `[`, keys, and commas as needed.
* `leafSegment` is used to determine if the innermost container is an array.
*
* Input: this.pathStack=[root], targetContainer=["recipe","ingredients"], leafSegment=0
* Output: '"recipe":{"ingredients":['
*/
private openDownTo(
targetContainer: PathSegment[],
leafSegment: PathSegment,
): string {
let fragment = '';
const startIdx = this.pathStack.length - 1;
for (let i = startIdx; i < targetContainer.length; i++) {
const seg = targetContainer[i];
const parentEntry = this.pathStack[this.pathStack.length - 1];
if (parentEntry.childCount > 0) {
fragment += ',';
}
parentEntry.childCount++;
if (typeof seg === 'string') {
fragment += `${JSON.stringify(seg)}:`;
}
const childSeg =
i + 1 < targetContainer.length ? targetContainer[i + 1] : leafSegment;
const isArray = typeof childSeg === 'number';
fragment += isArray ? '[' : '{';
this.pathStack.push({ segment: seg, isArray, childCount: 0 });
}
return fragment;
}
/**
* Emits the comma, key, and value for a leaf entry in the current container.
*
* Input: leafSegment="name", arg={stringValue:"Lasagna"}, valueJson='"Lasagna"'
* Output: '"name":"Lasagna"' (or ',"name":"Lasagna"' if container.childCount > 0)
*/
private emitLeaf(
leafSegment: PathSegment,
arg: PartialArg,
valueJson: string,
): string {
let fragment = '';
const container = this.pathStack[this.pathStack.length - 1];
if (container.childCount > 0) {
fragment += ',';
}
container.childCount++;
if (typeof leafSegment === 'string') {
fragment += `${JSON.stringify(leafSegment)}:`;
}
if (arg.stringValue != null && arg.willContinue) {
fragment += valueJson.slice(0, -1);
this.stringOpen = true;
} else {
fragment += valueJson;
}
return fragment;
}
}
/**
* Splits a dotted/bracketed JSON path like `recipe.ingredients[0].name` into segments.
*
* Input: "recipe.ingredients[0].name"
* Output: ["recipe", "ingredients", 0, "name"]
*/
function parsePath(rawPath: string): Array<string | number> {
const segments: Array<string | number> = [];
for (const part of rawPath.split('.')) {
const bracketIdx = part.indexOf('[');
if (bracketIdx === -1) {
segments.push(part);
} else {
if (bracketIdx > 0) segments.push(part.slice(0, bracketIdx));
for (const m of part.matchAll(/\[(\d+)\]/g)) {
segments.push(parseInt(m[1], 10));
}
}
}
return segments;
}
/**
* Traverses a nested object along the given path segments and returns the leaf value.
*
* Input: ({recipe:{name:"Lasagna"}}, ["recipe","name"])
* Output: "Lasagna"
*/
function getNestedValue(
obj: Record<string, unknown>,
segments: Array<string | number>,
): unknown {
let current: unknown = obj;
for (const seg of segments) {
if (current == null || typeof current !== 'object') return undefined;
current = (current as Record<string | number, unknown>)[seg];
}
return current;
}
/**
* Sets a value at a nested path, creating intermediate objects or arrays as needed.
*
* Input: obj={}, segments=["recipe","ingredients",0,"name"], value="Noodles"
* Output: {recipe:{ingredients:[{name:"Noodles"}]}}
*/
function setNestedValue(
obj: Record<string, unknown>,
segments: Array<string | number>,
value: unknown,
): void {
let current: Record<string | number, unknown> = obj;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
const nextSeg = segments[i + 1];
if (current[seg] == null) {
current[seg] = typeof nextSeg === 'number' ? [] : {};
}
current = current[seg] as Record<string | number, unknown>;
}
current[segments[segments.length - 1]] = value;
}
/**
* Extracts the first non-null typed value from a partial arg and returns it with its JSON representation.
*
* Input: arg={stringValue:"Boston"} or arg={numberValue:50}
* Output: {value:"Boston", json:'"Boston"'} or {value:50, json:'50'}
*/
function resolvePartialArgValue(arg: {
stringValue?: string | null;
numberValue?: number | null;
boolValue?: boolean | null;
nullValue?: unknown;
}): { value: unknown; json: string } | undefined {
const value = arg.stringValue ?? arg.numberValue ?? arg.boolValue;
if (value != null) return { value, json: JSON.stringify(value) };
if ('nullValue' in arg) return { value: null, json: 'null' };
return undefined;
}