Skip to content
Merged
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
102 changes: 98 additions & 4 deletions internal/aistudio/schema.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package aistudio

import (
"bytes"
"encoding/json"
"fmt"
"sort"
Expand All @@ -18,10 +19,18 @@ var schemaTypeCodes = map[string]int64{
}

func encodeJSONSchema(raw json.RawMessage) ([]any, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
raw = json.RawMessage(`{"type":"object","properties":{}}`)
}
var schema map[string]json.RawMessage
if err := json.Unmarshal(raw, &schema); err != nil || schema == nil {
return nil, fmt.Errorf("schema 必须是 JSON object")
}
cleanNullFields(schema)
if err := normalizeNot(schema); err != nil {
return nil, err
}
if err := normalizeConstAndMetadata(schema); err != nil {
return nil, err
}
Expand Down Expand Up @@ -85,11 +94,20 @@ func encodeJSONSchema(raw json.RawMessage) ([]any, error) {
wire = setWireField(wire, 4, values)
}
if value, ok := schema["items"]; ok {
items, err := encodeJSONSchema(value)
if err != nil {
return nil, fmt.Errorf("schema.items: %w", err)
itemTrimmed := bytes.TrimSpace(value)
if bytes.Equal(itemTrimmed, []byte("true")) {
value = json.RawMessage(`{}`)
} else if bytes.Equal(itemTrimmed, []byte("false")) {
delete(schema, "items")
value = nil
}
if value != nil {
items, err := encodeJSONSchema(value)
if err != nil {
return nil, fmt.Errorf("schema.items: %w", err)
}
wire = setWireField(wire, 5, items)
}
wire = setWireField(wire, 5, items)
}
for _, field := range []struct {
name string
Expand All @@ -115,6 +133,15 @@ func encodeJSONSchema(raw json.RawMessage) ([]any, error) {
if err := json.Unmarshal(value, &properties); err != nil || properties == nil {
return nil, fmt.Errorf("schema.properties 必须是 JSON object")
}
cleanNullFields(properties)
for name, rawProperty := range properties {
propTrimmed := bytes.TrimSpace(rawProperty)
if bytes.Equal(propTrimmed, []byte("true")) {
properties[name] = json.RawMessage(`{}`)
} else if bytes.Equal(propTrimmed, []byte("false")) {
delete(properties, name)
}
}
names := make([]string, 0, len(properties))
for name := range properties {
names = append(names, name)
Expand Down Expand Up @@ -485,3 +512,70 @@ func setWireField(wire []any, index int, value any) []any {
wire[index] = value
return wire
}

// cleanNullFields removes keys with explicit null values so optional schema fields do not fail validation
func cleanNullFields(schema map[string]json.RawMessage) {
for name, value := range schema {
if name == "const" {
continue
}
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
delete(schema, name)
}
}
}

// normalizeNot normalizes JSON Schema "not" constraints into supported structures or discards no-op forms
func normalizeNot(schema map[string]json.RawMessage) error {
raw, ok := schema["not"]
if !ok {
return nil
}
trimmed := bytes.TrimSpace(raw)
if bytes.Equal(trimmed, []byte("null")) ||
bytes.Equal(trimmed, []byte("false")) ||
bytes.Equal(trimmed, []byte("true")) ||
bytes.Equal(trimmed, []byte(`""`)) ||
len(trimmed) == 0 {
delete(schema, "not")
return nil
}
if trimmed[0] == '[' {
var arr []any
if err := json.Unmarshal(trimmed, &arr); err == nil {
wrapped, err := json.Marshal(map[string]any{"enum": arr})
if err != nil {
return err
}
schema["not"] = wrapped
return nil
}
}
if trimmed[0] != '{' {
var val any
if err := json.Unmarshal(trimmed, &val); err == nil {
wrapped, err := json.Marshal(map[string]any{"enum": []any{val}})
if err != nil {
return err
}
schema["not"] = wrapped
return nil
}
}
var sub map[string]json.RawMessage
if err := json.Unmarshal(trimmed, &sub); err == nil && sub != nil {
cleanNullFields(sub)
if len(sub) == 0 {
delete(schema, "not")
return nil
}
if typeRaw, ok := sub["type"]; ok {
var typeName string
if json.Unmarshal(typeRaw, &typeName) == nil && strings.EqualFold(typeName, "null") {
delete(schema, "not")
return nil
}
}
}
return nil
}
238 changes: 238 additions & 0 deletions internal/aistudio/schema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package aistudio

import (
"encoding/json"
"testing"
)

func TestEncodeJSONSchema_NullFields(t *testing.T) {
fields := []string{
`"format": null`,
`"description": null`,
`"nullable": null`,
`"enum": null`,
`"items": null`,
`"properties": null`,
`"required": null`,
`"minItems": null`,
`"maxItems": null`,
`"minProperties": null`,
`"maxProperties": null`,
`"minimum": null`,
`"maximum": null`,
`"minLength": null`,
`"maxLength": null`,
`"pattern": null`,
`"example": null`,
`"oneOf": null`,
`"anyOf": null`,
`"allOf": null`,
`"not": null`,
`"default": null`,
}

for _, f := range fields {
schema := `{
"type": "string",
` + f + `
}`
wire, err := encodeJSONSchema(json.RawMessage(schema))
if err != nil {
t.Fatalf("Field %s caused error: %v", f, err)
}
if wire == nil {
t.Fatalf("Field %s resulted in nil wire schema", f)
}
}
}

func TestEncodeJSONSchema_NotVariants(t *testing.T) {
cases := []struct {
name string
schema string
}{
{
name: "not null",
schema: `{
"type": "object",
"properties": {
"model": {
"type": "string",
"not": null
}
}
}`,
},
{
name: "not boolean false",
schema: `{
"type": "object",
"properties": {
"model": {
"type": "string",
"not": false
}
}
}`,
},
{
name: "not boolean true",
schema: `{
"type": "object",
"properties": {
"model": {
"type": "string",
"not": true
}
}
}`,
},
{
name: "not empty object",
schema: `{
"type": "object",
"properties": {
"model": {
"type": "string",
"not": {}
}
}
}`,
},
{
name: "not type null",
schema: `{
"type": "object",
"properties": {
"model": {
"type": "string",
"not": {
"type": "null"
}
}
}
}`,
},
{
name: "not string array",
schema: `{
"type": "object",
"properties": {
"model": {
"type": "string",
"not": ["gpt-3.5", "gpt-4"]
}
}
}`,
},
{
name: "not single string",
schema: `{
"type": "object",
"properties": {
"model": {
"type": "string",
"not": "gpt-3.5"
}
}
}`,
},
{
name: "not valid schema object",
schema: `{
"type": "object",
"properties": {
"model": {
"type": "string",
"not": {
"enum": ["gpt-3.5"]
}
}
}
}`,
},
{
name: "null property in properties",
schema: `{
"type": "object",
"properties": {
"model": null,
"valid": {
"type": "string"
}
}
}`,
},
{
name: "boolean properties",
schema: `{
"type": "object",
"properties": {
"allow": true,
"disallow": false
}
}`,
},
{
name: "boolean items",
schema: `{
"type": "array",
"items": true
}`,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
wire, err := encodeJSONSchema(json.RawMessage(tc.schema))
if err != nil {
t.Fatalf("unexpected error for %q: %v", tc.name, err)
}
if wire == nil {
t.Fatalf("expected non-nil wire schema for %q", tc.name)
}
})
}
}

func TestEncodeJSONSchema_TopLevelNullOrEmpty(t *testing.T) {
cases := []struct {
name string
raw string
}{
{"null", `null`},
{"empty string", ``},
{"spaces", ` `},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
wire, err := encodeJSONSchema(json.RawMessage(tc.raw))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if wire == nil {
t.Fatal("expected non-nil wire")
}
})
}
}

func TestEncodeJSONSchema_DirectConst(t *testing.T) {
input := `{
"type": "object",
"properties": {
"type": {
"const": "tool_call",
"title": "Type"
}
}
}`

wire, err := encodeJSONSchema(json.RawMessage(input))
if err != nil {
t.Fatalf("encodeJSONSchema failed: %v", err)
}
if wire == nil {
t.Fatal("expected non-nil wire schema")
}
}