Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-hats-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ai-sdk/google': patch
---

Added support for gemini's propertyOrdering feature
90 changes: 90 additions & 0 deletions packages/google/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,96 @@ const { text } = await generateText({
});
```

## Property Ordering for Structured Output

When using structured output with Google Generative AI models, you can control the order of properties in JSON responses using the `propertyOrdering` provider option. This ensures consistent property ordering and can improve response quality.

### Basic Example - Simple Array Format

For simple root-level property ordering, you can use an array:

```ts
import { google } from '@ai-sdk/google';
import { generateObject } from 'ai';
import { z } from 'zod';

const result = await generateObject({
model: google('gemini-2.0-flash'),
providerOptions: {
google: {
propertyOrdering: ['name', 'age', 'email'], // Simple array for root properties
},
},
schema: z.object({
name: z.string(),
age: z.number(),
email: z.string(),
}),
prompt: 'Generate a person profile',
});
```

### Object Format for Root-Level Properties

Alternatively, you can use the object format:

```ts
const result = await generateObject({
model: google('gemini-2.0-flash'),
providerOptions: {
google: {
propertyOrdering: {
'': ['name', 'age', 'email'], // Root level properties using object format
},
},
},
schema: z.object({
name: z.string(),
age: z.number(),
email: z.string(),
}),
prompt: 'Generate a person profile',
});
```

### Nested Object Ordering

For complex nested objects, use dot notation to specify property ordering at each level:

```ts
const result = await generateObject({
model: google('gemini-2.0-flash'),
providerOptions: {
google: {
propertyOrdering: {
'': ['name', 'profile', 'preferences'],
profile: ['bio', 'settings', 'contacts'],
'profile.settings': ['theme', 'notifications'],
preferences: ['language', 'timezone'],
},
},
},
schema: z.object({
name: z.string(),
profile: z.object({
bio: z.string(),
settings: z.object({
theme: z.string(),
notifications: z.boolean(),
}),
contacts: z.array(z.string()),
}),
preferences: z.object({
language: z.string(),
timezone: z.string(),
}),
}),
prompt: 'Generate a comprehensive user profile',
});
```

The property ordering follows Google's [structured output documentation](https://ai.google.dev/gemini-api/docs/structured-output#property-ordering) and helps ensure consistent, predictable JSON responses from Gemini models.

## Documentation

Please check out the **[Google Generative AI provider documentation](https://ai-sdk.dev/providers/ai-sdk-providers/google-generative-ai)** for more information.
121 changes: 121 additions & 0 deletions packages/google/src/convert-json-schema-to-openapi-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,3 +581,124 @@ it('should convert nullable string enum', () => {
},
});
});

it('should add propertyOrdering when provided', () => {
const input: JSONSchema7 = {
type: 'object',
properties: {
name: { type: 'string' },
profile: {
type: 'object',
properties: {
bio: { type: 'string' },
settings: {
type: 'object',
properties: {
theme: { type: 'string' },
notifications: { type: 'boolean' },
},
},
contacts: { type: 'array', items: { type: 'string' } },
},
},
preferences: {
type: 'object',
properties: {
language: { type: 'string' },
timezone: { type: 'string' },
},
},
},
};

const propertyOrdering = {
'': ['name', 'profile', 'preferences'],
profile: ['bio', 'settings', 'contacts'],
'profile.settings': ['theme', 'notifications'],
preferences: ['language', 'timezone'],
};

const expected = {
type: 'object',
properties: {
name: { type: 'string' },
profile: {
type: 'object',
properties: {
bio: { type: 'string' },
settings: {
type: 'object',
properties: {
theme: { type: 'string' },
notifications: { type: 'boolean' },
},
propertyOrdering: ['theme', 'notifications'],
},
contacts: { type: 'array', items: { type: 'string' } },
},
propertyOrdering: ['bio', 'settings', 'contacts'],
},
preferences: {
type: 'object',
properties: {
language: { type: 'string' },
timezone: { type: 'string' },
},
propertyOrdering: ['language', 'timezone'],
},
},
propertyOrdering: ['name', 'profile', 'preferences'],
};

expect(convertJSONSchemaToOpenAPISchema(input, propertyOrdering)).toEqual(
expected,
);
});

it('should work without propertyOrdering', () => {
const input: JSONSchema7 = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
};

const expected = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
};

expect(convertJSONSchemaToOpenAPISchema(input)).toEqual(expected);
});

it('should support simple array format for root-level property ordering', () => {
const input: JSONSchema7 = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
email: { type: 'string' },
},
};

// Simple array format for root-level ordering
const propertyOrdering = ['name', 'email', 'age'];

const expected = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
email: { type: 'string' },
},
propertyOrdering: ['name', 'email', 'age'],
};

expect(convertJSONSchemaToOpenAPISchema(input, propertyOrdering)).toEqual(
expected,
);
});
72 changes: 64 additions & 8 deletions packages/google/src/convert-json-schema-to-openapi-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@ import { JSONSchema7Definition } from '@ai-sdk/provider';
*/
export function convertJSONSchemaToOpenAPISchema(
jsonSchema: JSONSchema7Definition | undefined,
propertyOrdering?: Record<string, string[]> | string[],
currentPath: string = '',
): unknown {
// parameters need to be undefined if they are empty objects:
if (jsonSchema == null || isEmptyObjectSchema(jsonSchema)) {
return undefined;
}

// Normalize propertyOrdering: convert array format to object format
const normalizedPropertyOrdering: Record<string, string[]> | undefined =
Array.isArray(propertyOrdering)
? { '': propertyOrdering }
: propertyOrdering;

if (typeof jsonSchema === 'boolean') {
return { type: 'boolean', properties: {} };
}
Expand Down Expand Up @@ -64,21 +72,47 @@ export function convertJSONSchemaToOpenAPISchema(
if (properties != null) {
result.properties = Object.entries(properties).reduce(
(acc, [key, value]) => {
acc[key] = convertJSONSchemaToOpenAPISchema(value);
const nestedPath = currentPath ? `${currentPath}.${key}` : key;
acc[key] = convertJSONSchemaToOpenAPISchema(
value,
normalizedPropertyOrdering,
nestedPath,
);
return acc;
},
{} as Record<string, unknown>,
);

// Add propertyOrdering if it exists for this path
if (normalizedPropertyOrdering && normalizedPropertyOrdering[currentPath]) {
result.propertyOrdering = normalizedPropertyOrdering[currentPath];
}
}

if (items) {
result.items = Array.isArray(items)
? items.map(convertJSONSchemaToOpenAPISchema)
: convertJSONSchemaToOpenAPISchema(items);
? items.map((item, index) =>
convertJSONSchemaToOpenAPISchema(
item,
normalizedPropertyOrdering,
`${currentPath}[${index}]`,
),
)
: convertJSONSchemaToOpenAPISchema(
items,
normalizedPropertyOrdering,
`${currentPath}[]`,
);
}

if (allOf) {
result.allOf = allOf.map(convertJSONSchemaToOpenAPISchema);
result.allOf = allOf.map(schema =>
convertJSONSchemaToOpenAPISchema(
schema,
normalizedPropertyOrdering,
currentPath,
),
);
}
if (anyOf) {
// Handle cases where anyOf includes a null type
Expand All @@ -93,22 +127,44 @@ export function convertJSONSchemaToOpenAPISchema(

if (nonNullSchemas.length === 1) {
// If there's only one non-null schema, convert it and make it nullable
const converted = convertJSONSchemaToOpenAPISchema(nonNullSchemas[0]);
const converted = convertJSONSchemaToOpenAPISchema(
nonNullSchemas[0],
normalizedPropertyOrdering,
currentPath,
);
if (typeof converted === 'object') {
result.nullable = true;
Object.assign(result, converted);
}
} else {
// If there are multiple non-null schemas, keep them in anyOf
result.anyOf = nonNullSchemas.map(convertJSONSchemaToOpenAPISchema);
result.anyOf = nonNullSchemas.map(schema =>
convertJSONSchemaToOpenAPISchema(
schema,
normalizedPropertyOrdering,
currentPath,
),
);
result.nullable = true;
}
} else {
result.anyOf = anyOf.map(convertJSONSchemaToOpenAPISchema);
result.anyOf = anyOf.map(schema =>
convertJSONSchemaToOpenAPISchema(
schema,
normalizedPropertyOrdering,
currentPath,
),
);
}
}
if (oneOf) {
result.oneOf = oneOf.map(convertJSONSchemaToOpenAPISchema);
result.oneOf = oneOf.map(schema =>
convertJSONSchemaToOpenAPISchema(
schema,
normalizedPropertyOrdering,
currentPath,
),
);
}

if (minLength !== undefined) {
Expand Down
5 changes: 4 additions & 1 deletion packages/google/src/google-generative-ai-language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,10 @@ export class GoogleGenerativeAILanguageModel implements LanguageModelV2 {
// so this is needed as an escape hatch:
// TODO convert into provider option
(googleOptions?.structuredOutputs ?? true)
? convertJSONSchemaToOpenAPISchema(responseFormat.schema)
? convertJSONSchemaToOpenAPISchema(
responseFormat.schema,
googleOptions?.propertyOrdering,
)
: undefined,
...(googleOptions?.audioTimestamp && {
audioTimestamp: googleOptions.audioTimestamp,
Expand Down
13 changes: 13 additions & 0 deletions packages/google/src/google-generative-ai-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,19 @@ Optional. A list of unique safety settings for blocking unsafe content.
* https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/add-labels-to-api-calls
*/
labels: z.record(z.string(), z.string()).optional(),

/**
* Optional. Defines the ordering of the properties in the response.
*
* Can be either:
* - A simple array for root-level properties: ['name', 'age', 'email']
* - An object for nested properties: { '': ['name', 'profile'], 'profile': ['bio', 'settings'] }
*
* https://ai.google.dev/gemini-api/docs/structured-output?lang=node#property-ordering
*/
propertyOrdering: z
.union([z.array(z.string()), z.record(z.string(), z.array(z.string()))])
.optional(),
});

export type GoogleGenerativeAIProviderOptions = z.infer<
Expand Down