diff --git a/internal/aistudio/schema.go b/internal/aistudio/schema.go index ead8821..76d4459 100644 --- a/internal/aistudio/schema.go +++ b/internal/aistudio/schema.go @@ -1,6 +1,7 @@ package aistudio import ( + "bytes" "encoding/json" "fmt" "sort" @@ -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 } @@ -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 @@ -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) @@ -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 +} diff --git a/internal/aistudio/schema_test.go b/internal/aistudio/schema_test.go new file mode 100644 index 0000000..6e8b9fe --- /dev/null +++ b/internal/aistudio/schema_test.go @@ -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") + } +}