Skip to content
Open
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
115 changes: 109 additions & 6 deletions packages/cli/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,65 @@ import { Flag } from "@/flag/flag"

type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]

const GEMINI_ANNOTATIONS = new Set([
"$schema",
"$id",
"$anchor",
"$comment",
"title",
"description",
"default",
"examples",
"example",
"deprecated",
"readOnly",
"writeOnly",
])
const GEMINI_ROOT_KEYS = new Set([...GEMINI_ANNOTATIONS, "$defs", "definitions", "defs"])
const GEMINI_KEYWORD_TYPES = new Map<string, readonly string[]>([
...[
"properties",
"required",
"additionalProperties",
"patternProperties",
"propertyNames",
"minProperties",
"maxProperties",
"dependencies",
"dependentRequired",
"dependentSchemas",
"unevaluatedProperties",
"propertyOrdering",
].map((key) => [key, ["object"]] as const),
...[
"items",
"prefixItems",
"additionalItems",
"contains",
"minContains",
"maxContains",
"minItems",
"maxItems",
"uniqueItems",
"unevaluatedItems",
].map((key) => [key, ["array"]] as const),
...["minLength", "maxLength", "pattern", "contentEncoding", "contentMediaType", "contentSchema"].map(
(key) => [key, ["string"]] as const,
),
...["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].map(
(key) => [key, ["number", "integer"]] as const,
),
["format", ["string", "number", "integer"]],
])

function isPlainObject(node: unknown): node is Record<string, any> {
return typeof node === "object" && node !== null && !Array.isArray(node)
}

function hasCombiner(node: unknown) {
return isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf))
}

function mimeToModality(mime: string): Modality | undefined {
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
Expand Down Expand Up @@ -938,6 +997,10 @@ export namespace ProviderTransform {

// Convert integer enums to string enums for Google/Gemini
if (model.providerID === "google" || model.api.id.includes("gemini")) {
// Native Google adapters already convert type arrays and preserve nullability.
// Pre-converting them here causes the pinned adapters to drop `nullable`.
const native = model.api.npm === "@ai-sdk/google" || model.api.npm === "@ai-sdk/google-vertex"

const sanitizeGemini = (obj: any): any => {
if (obj === null || typeof obj !== "object") {
return obj
Expand All @@ -963,28 +1026,68 @@ export namespace ProviderTransform {
}
}

if (!native && Array.isArray(result.type) && result.type.length === 0) {
throw new Error("Gemini tool schema contains an empty type array")
}
const types = Array.isArray(result.type) ? result.type : result.type ? [result.type] : []
const composed = hasCombiner(result)

// Filter required array to only include fields that exist in properties
if (result.type === "object" && result.properties && Array.isArray(result.required)) {
if (types.includes("object") && !composed && result.properties && Array.isArray(result.required)) {
result.required = result.required.filter((field: any) => field in result.properties)
}

if (result.type === "array") {
if (types.includes("array") && !composed) {
if (result.items == null) {
result.items = {}
}
// Ensure items has at least a type if it's an empty object
// This handles nested arrays like { type: "array", items: { type: "array", items: {} } }
if (typeof result.items === "object" && !Array.isArray(result.items) && !result.items.type) {
if (
isPlainObject(result.items) &&
Object.keys(result.items).every(
(key) => GEMINI_ANNOTATIONS.has(key) || (key === "enum" && Array.isArray(result.items.enum)),
)
) {
// Empty/annotation-only items retain the existing string default;
// enum-only items have already had their values converted to strings.
result.items.type = "string"
}
}

// Remove properties/required from non-object types (Gemini rejects these)
if (result.type && result.type !== "object") {
if (types.length > 0 && !types.includes("object") && !composed) {
delete result.properties
delete result.required
}

// Apply type-specific cleanup before splitting; afterwards type no longer
// identifies whether the union contains object or array members.
// Native adapters own this conversion to preserve their nullability rules.
if (Array.isArray(result.type) && !native) {
const nullable = types.includes("null")
const nonNull = types.filter((entry: unknown) => entry !== "null")
if (nonNull.length === 0) {
result.type = "null"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Null-only type array emits bare type:"null".

Suggested change
result.type = "null"
Emit `result.nullable = true` and delete `result.type` (leaving an unconstrained nullable schema) instead of `result.type = "null"`; update the `["null"]` test expectation accordingly.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:1030):

Problem: Null-only type array emits bare type:"null"
Detail: When a type array contains only "null" (e.g. `type: ["null"]`), the non-native split sets `result.type = "null"`. Gemini's Schema.type enum has no NULL value — nullability is expressed via `nullable`, which is exactly what every other branch in this same block does (anyOf + `nullable: true`). On the non-native transports this PR targets precisely because their translation layer does not normalize, a bare `{type:"null"}` node is invalid on the wire (or coerced to TYPE_UNSPECIFIED). Not a regression (pre-PR sent `["null"]` raw, equally invalid), but the rewrite should match the convention it establishes everywhere else. The "collapses a null-only type array" test pins the current shape.
Suggested fix: Emit `result.nullable = true` and delete `result.type` (leaving an unconstrained nullable schema) instead of `result.type = "null"`; update the `["null"]` test expectation accordingly.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

When a type array contains only "null" (e.g. type: ["null"]), the non-native split sets result.type = "null". Gemini's Schema.type enum has no NULL value — nullability is expressed via nullable, which is exactly what every other branch in this same block does (anyOf + nullable: true). On the non-native transports this PR targets precisely because their translation layer does not normalize, a bare {type:"null"} node is invalid on the wire (or coerced to TYPE_UNSPECIFIED). Not a regression (pre-PR sent ["null"] raw, equally invalid), but the rewrite should match the convention it establishes everywhere else. The "collapses a null-only type array" test pins the current shape.

        if (Array.isArray(result.type) && !composed && !native) {
          const nullable = types.includes("null")
          const nonNull = types.filter((entry: unknown) => entry !== "null")
          if (nonNull.length === 0) {
            result.type = "null"
          } else if (nonNull.length === 1 && !nullable) {
            result.type = nonNull[0]
          } else {

} else if (nonNull.length === 1 && !nullable) {
result.type = nonNull[0]
} else {
const entries = Object.entries(result).filter(([key]) => key !== "type")
const constraints = entries.filter(([key]) => !GEMINI_ROOT_KEYS.has(key))
// Universal constraints (including existing combiners and enum) can
// exclude null. Keep an explicit constrained null branch in that case.
const constrained = constraints.some(([key]) => !GEMINI_KEYWORD_TYPES.has(key))
return {
...Object.fromEntries(entries.filter(([key]) => GEMINI_ROOT_KEYS.has(key))),
anyOf: (constrained ? types : nonNull).map((entry: string) => ({
type: entry,
...Object.fromEntries(
constraints.filter(([key]) => GEMINI_KEYWORD_TYPES.get(key)?.includes(entry) ?? true),
),
})),
...(!constrained && nullable ? { nullable: true } : {}),
}
}
}

return result
}

Expand Down
Loading