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
258 changes: 254 additions & 4 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -871,17 +871,262 @@ export namespace ProviderTransform {

// Convert integer enums to string enums for Google/Gemini
if (model.providerID === "google" || model.api.id.includes("gemini")) {
const sanitizeGemini = (obj: any): any => {
const sanitizeGemini = (obj: any, rootSchema: any): any => {
if (obj === null || typeof obj !== "object") {
return obj
}

if (Array.isArray(obj)) {
return obj.map(sanitizeGemini)
return obj.map((item) => sanitizeGemini(item, rootSchema))
}

// Resolve local refs when possible before keyword stripping
if (typeof obj.$ref === "string") {
let resolved: any
if (obj.$ref.startsWith("#/$defs/")) {
const defName = obj.$ref.slice("#/$defs/".length)
resolved = rootSchema?.$defs?.[defName]
} else if (obj.$ref.startsWith("#/definitions/")) {
const defName = obj.$ref.slice("#/definitions/".length)
resolved = rootSchema?.definitions?.[defName]
}

if (resolved && typeof resolved === "object") {
const merged: any = { ...resolved, ...obj }
delete merged.$ref
return sanitizeGemini(merged, rootSchema)
}
}

// Merge allOf into a single schema before processing
if (Array.isArray(obj.allOf)) {
const mergedAllOf: any = {}
const mergedRequired = new Set<any>()
let mergedProperties: Record<string, any> = {}
let hasMergedProperties = false
let mergedItems: any

for (const variant of obj.allOf) {
if (!variant || typeof variant !== "object") {
continue
}

if (mergedAllOf.type === undefined && variant.type !== undefined) {
mergedAllOf.type = variant.type
}

if (mergedAllOf.description === undefined && variant.description !== undefined) {
mergedAllOf.description = variant.description
}

if (variant.properties && typeof variant.properties === "object" && !Array.isArray(variant.properties)) {
mergedProperties = {
...mergedProperties,
...variant.properties,
}
hasMergedProperties = true
}

if (Array.isArray(variant.required)) {
for (const field of variant.required) {
mergedRequired.add(field)
}
}

if (variant.items !== undefined) {
if (
mergedItems &&
typeof mergedItems === "object" &&
!Array.isArray(mergedItems) &&
variant.items &&
typeof variant.items === "object" &&
!Array.isArray(variant.items)
) {
mergedItems = {
...mergedItems,
...variant.items,
}
} else {
mergedItems = variant.items
}
}
}

if (hasMergedProperties) {
mergedAllOf.properties = mergedProperties
}

if (mergedRequired.size > 0) {
mergedAllOf.required = Array.from(mergedRequired)
}

if (mergedItems !== undefined) {
mergedAllOf.items = mergedItems
}

const merged: any = { ...mergedAllOf, ...obj }
delete merged.allOf

if (
mergedAllOf.properties &&
merged.properties &&
typeof merged.properties === "object" &&
!Array.isArray(merged.properties)
) {
merged.properties = {
...mergedAllOf.properties,
...merged.properties,
}
}

if (Array.isArray(mergedAllOf.required) || Array.isArray(obj.required)) {
const required = new Set<any>()
if (Array.isArray(mergedAllOf.required)) {
for (const field of mergedAllOf.required) {
required.add(field)
}
}
if (Array.isArray(obj.required)) {
for (const field of obj.required) {
required.add(field)
}
}
merged.required = Array.from(required)
}

if (mergedAllOf.items !== undefined && obj.items !== undefined) {
if (
mergedAllOf.items &&
typeof mergedAllOf.items === "object" &&
!Array.isArray(mergedAllOf.items) &&
obj.items &&
typeof obj.items === "object" &&
!Array.isArray(obj.items)
) {
merged.items = {
...mergedAllOf.items,
...obj.items,
}
} else {
merged.items = obj.items
}
}

return sanitizeGemini(merged, rootSchema)
}

// Convert anyOf/oneOf with const values to enum before processing
if (obj.anyOf || obj.oneOf) {
const variants = obj.anyOf || obj.oneOf
if (Array.isArray(variants)) {
const constValues = variants
.filter((v: any) => v && typeof v === "object" && "const" in v)
.map((v: any) => String(v.const))
if (constValues.length === variants.length && constValues.length > 0) {
const merged: any = { ...obj, type: "string", enum: constValues }
delete merged.anyOf
delete merged.oneOf
delete merged.const
return sanitizeGemini(merged, rootSchema)
}
// Merge object variants when all typed variants are objects
const typeVariants = variants.filter((v: any) => v && typeof v === "object" && v.type)
const objectVariants = typeVariants.filter((v: any) => v.type === "object")
if (objectVariants.length > 0 && objectVariants.length === typeVariants.length) {
const merged: any = { ...obj, type: "object" }
const mergedRequired = new Set<any>()
let mergedProperties: Record<string, any> = {}
let hasMergedProperties = false

for (const variant of objectVariants) {
if (variant.properties && typeof variant.properties === "object" && !Array.isArray(variant.properties)) {
mergedProperties = {
...mergedProperties,
...variant.properties,
}
hasMergedProperties = true
}
if (Array.isArray(variant.required)) {
for (const field of variant.required) {
mergedRequired.add(field)
}
}
}

if (hasMergedProperties) {
if (merged.properties && typeof merged.properties === "object" && !Array.isArray(merged.properties)) {
merged.properties = {
...mergedProperties,
...merged.properties,
}
} else {
merged.properties = mergedProperties
}
}

if (mergedRequired.size > 0 || Array.isArray(merged.required)) {
const required = new Set<any>()
if (Array.isArray(merged.required)) {
for (const field of merged.required) {
required.add(field)
}
}
for (const field of mergedRequired) {
required.add(field)
}
merged.required = Array.from(required)
}

delete merged.anyOf
delete merged.oneOf
return sanitizeGemini(merged, rootSchema)
}

// If anyOf/oneOf contains mixed type variants, pick the first valid one
if (typeVariants.length > 0) {
const merged: any = { ...obj, ...typeVariants[0] }
delete merged.anyOf
delete merged.oneOf
return sanitizeGemini(merged, rootSchema)
}
}
}

const result: any = {}
for (const [key, value] of Object.entries(obj)) {
// Skip keywords unsupported by Gemini
if (
key === "additionalProperties" ||
key === "$ref" ||
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The code strips $ref without resolving it to the referenced schema. Issue #12295 mentions that Gemini API rejects schemas containing $ref references and expects them to be expanded. Simply removing $ref will result in an incomplete schema (e.g., {"$ref": "#/$defs/MyType", "description": "..."} becomes just {"description": "..."}). To properly fix this issue, $ref should be resolved by looking up the reference in $defs/definitions and merging the referenced schema into the current location before stripping occurs.

Copilot uses AI. Check for mistakes.
key === "$schema" ||
key === "$id" ||
key === "$defs" ||
key === "definitions" ||
key === "default" ||
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The code strips the "default" keyword which may result in loss of default values that could be useful for API consumers. According to issue #12295, Gemini allows "default" alongside $ref, suggesting it may be supported in some contexts. Consider verifying through testing whether "default" needs to be stripped in all contexts or only in specific ones, and document the reasoning for stripping it if it's necessary for API compatibility.

Copilot uses AI. Check for mistakes.
key === "const" ||
key === "minItems" ||
key === "maxItems" ||
key === "minLength" ||
key === "maxLength" ||
key === "pattern" ||
key === "patternProperties" ||
key === "propertyNames" ||
key === "uniqueItems" ||
key === "minimum" ||
key === "maximum" ||
key === "exclusiveMinimum" ||
key === "exclusiveMaximum" ||
key === "multipleOf" ||
key === "if" ||
key === "then" ||
key === "else" ||
key === "not" ||
key === "title" ||
key === "anyOf" ||
key === "oneOf"
) {
continue
}
if (key === "enum" && Array.isArray(value)) {
// Convert all enum values to strings
result[key] = value.map((v) => String(v))
Expand All @@ -890,12 +1135,17 @@ export namespace ProviderTransform {
result.type = "string"
}
} else if (typeof value === "object" && value !== null) {
result[key] = sanitizeGemini(value)
result[key] = sanitizeGemini(value, rootSchema)
} else {
result[key] = value
}
}

// Infer type="object" when properties exist but type is missing
if (!result.type && (result.properties || result.required || obj === rootSchema)) {
result.type = "object"
}

// Filter required array to only include fields that exist in properties
if (result.type === "object" && result.properties && Array.isArray(result.required)) {
result.required = result.required.filter((field: any) => field in result.properties)
Expand All @@ -921,7 +1171,7 @@ export namespace ProviderTransform {
return result
}

schema = sanitizeGemini(schema)
schema = sanitizeGemini(schema, schema)
}

return schema as JSONSchema7
Expand Down
Loading
Loading