mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 22:14:37 +00:00
Resolve the PR #8/#9/#10 stack on current dev, including Bitrix24 install callback hardening, migration renumbering, duplicate-domain fail-closed routing, UI textarea/mobile cleanup, and review hardening.
157 lines
4.5 KiB
Go
157 lines
4.5 KiB
Go
package providers
|
|
|
|
// schema_strict.go — OpenAI strict tool mode transform.
|
|
// Converts optional properties to nullable unions, requires all properties,
|
|
// and sets additionalProperties:false so constrained decoding can enforce schema compliance.
|
|
|
|
// applyStrictMode transforms a tool's JSON Schema for OpenAI strict mode.
|
|
// - Optional properties (not in "required") get their type changed to ["<type>", "null"]
|
|
// - ALL property names are added to "required"
|
|
// - "additionalProperties": false is set on object schemas
|
|
// This is applied recursively to nested object schemas.
|
|
func applyStrictMode(schema map[string]any, depth int) map[string]any {
|
|
if schema == nil || depth > maxSchemaDepth {
|
|
return schema
|
|
}
|
|
|
|
typ, _ := schema["type"].(string)
|
|
props, hasProps := schema["properties"].(map[string]any)
|
|
|
|
if typ != "object" {
|
|
return schema
|
|
}
|
|
// Bare object schema (type:"object" with no inner "properties"). OpenAI
|
|
// strict mode still requires additionalProperties:false on such nodes —
|
|
// otherwise the later makeNullable transform turns this into
|
|
// type:["object","null"] and the strict validator rejects the null-guarded
|
|
// "object" variant for lacking additionalProperties. Set it here so tool
|
|
// authors who write `{"type":"object","description":"..."}` for a bag of
|
|
// free-form params don't produce invalid_function_parameters errors.
|
|
if !hasProps {
|
|
if _, already := schema["additionalProperties"]; !already {
|
|
schema["additionalProperties"] = false
|
|
}
|
|
return schema
|
|
}
|
|
|
|
// Build the set of currently required properties.
|
|
reqSet := make(map[string]bool)
|
|
if reqArr, ok := schema["required"].([]any); ok {
|
|
for _, r := range reqArr {
|
|
if s, ok := r.(string); ok {
|
|
reqSet[s] = true
|
|
}
|
|
}
|
|
}
|
|
if reqArr, ok := schema["required"].([]string); ok {
|
|
for _, s := range reqArr {
|
|
reqSet[s] = true
|
|
}
|
|
}
|
|
|
|
// Collect all property names for the new required array.
|
|
allRequired := make([]any, 0, len(props))
|
|
|
|
for name, prop := range props {
|
|
allRequired = append(allRequired, name)
|
|
|
|
pm, ok := prop.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Recurse into nested objects first.
|
|
pm = applyStrictMode(pm, depth+1)
|
|
props[name] = pm
|
|
|
|
// Recurse into array items.
|
|
if items, ok := pm["items"].(map[string]any); ok {
|
|
pm["items"] = applyStrictMode(items, depth+1)
|
|
}
|
|
|
|
// Already required — no need to make nullable.
|
|
if reqSet[name] {
|
|
continue
|
|
}
|
|
|
|
// Make optional property nullable: type:"string" → type:["string","null"]
|
|
makeNullable(pm)
|
|
}
|
|
|
|
schema["properties"] = props
|
|
schema["required"] = allRequired
|
|
schema["additionalProperties"] = false
|
|
|
|
return schema
|
|
}
|
|
|
|
// IsMultiActionSchema detects tool schemas using the multi-action pattern:
|
|
// a top-level "action" property with an "enum" list of 2+ values.
|
|
// These tools have many optional params shared across actions;
|
|
// strict mode forces models to send all params for every call.
|
|
func IsMultiActionSchema(schema map[string]any) bool {
|
|
props, ok := schema["properties"].(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
actionProp, ok := props["action"].(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
enumVal, ok := actionProp["enum"]
|
|
if !ok {
|
|
return false
|
|
}
|
|
// Only exempt if 2+ actions — single-action tools benefit from strict.
|
|
switch e := enumVal.(type) {
|
|
case []string:
|
|
return len(e) >= 2
|
|
case []any:
|
|
return len(e) >= 2
|
|
}
|
|
return false
|
|
}
|
|
|
|
// makeNullable converts a property schema to accept null values.
|
|
// - Simple type: "string" → ["string", "null"]
|
|
// - Type array: ["string", "integer"] → ["string", "integer", "null"]
|
|
// - anyOf/oneOf: appends {"type":"null"} variant
|
|
// - No type: adds type:["null"] (fallback)
|
|
func makeNullable(schema map[string]any) {
|
|
// Already has null variant in anyOf/oneOf — skip.
|
|
for _, key := range []string{"anyOf", "oneOf"} {
|
|
if variants, ok := schema[key].([]any); ok {
|
|
for _, v := range variants {
|
|
if m, ok := v.(map[string]any); ok && isNullSchema(m) {
|
|
return // already nullable
|
|
}
|
|
}
|
|
// Append null variant.
|
|
schema[key] = append(variants, map[string]any{"type": "null"})
|
|
return
|
|
}
|
|
}
|
|
|
|
typ, hasType := schema["type"]
|
|
|
|
switch t := typ.(type) {
|
|
case string:
|
|
if t == "null" {
|
|
return // already null
|
|
}
|
|
schema["type"] = []any{t, "null"}
|
|
case []any:
|
|
for _, v := range t {
|
|
if s, ok := v.(string); ok && s == "null" {
|
|
return // already has null
|
|
}
|
|
}
|
|
schema["type"] = append(t, "null")
|
|
default:
|
|
if !hasType {
|
|
// No type field — add nullable object as fallback.
|
|
schema["type"] = []any{"object", "null"}
|
|
}
|
|
}
|
|
}
|