diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 70dee027c1..0abd14937a 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -3542,7 +3542,7 @@ func cleanToolSchema(schema any) any { if key == "$schema" || key == "$id" || key == "$ref" || key == "$defs" || key == "definitions" || key == "additionalProperties" || key == "patternProperties" || key == "minLength" || - key == "maxLength" || key == "minItems" || key == "maxItems" { + key == "maxLength" || key == "minItems" || key == "maxItems" || key == "exclusiveMinimum" { continue } // 递归清理嵌套对象 @@ -3563,6 +3563,13 @@ func cleanToolSchema(schema any) any { delete(cleaned, "type") } } + if cleaned["type"] == "INTEGER" { + if minimum, ok := incrementIntegralSchemaBound(v["exclusiveMinimum"]); ok { + if existing, exists := cleaned["minimum"]; !exists || schemaNumberLess(existing, minimum) { + cleaned["minimum"] = minimum + } + } + } return cleaned case []any: cleaned := make([]any, len(v)) @@ -3575,6 +3582,56 @@ func cleanToolSchema(schema any) any { } } +func incrementIntegralSchemaBound(value any) (any, bool) { + switch v := value.(type) { + case float64: + if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) || v+1 <= v { + return nil, false + } + return v + 1, true + case int: + if v == math.MaxInt { + return nil, false + } + return v + 1, true + case int64: + if v == math.MaxInt64 { + return nil, false + } + return v + 1, true + case json.Number: + i, err := v.Int64() + if err != nil || i == math.MaxInt64 { + return nil, false + } + return json.Number(fmt.Sprintf("%d", i+1)), true + default: + return nil, false + } +} + +func schemaNumberLess(left, right any) bool { + leftNumber, leftOK := schemaNumberFloat64(left) + rightNumber, rightOK := schemaNumberFloat64(right) + return leftOK && rightOK && leftNumber < rightNumber +} + +func schemaNumberFloat64(value any) (float64, bool) { + switch v := value.(type) { + case float64: + return v, !math.IsNaN(v) && !math.IsInf(v, 0) + case int: + return float64(v), true + case int64: + return float64(v), true + case json.Number: + n, err := v.Float64() + return n, err == nil && !math.IsInf(n, 0) + default: + return 0, false + } +} + func convertClaudeGenerationConfig(req map[string]any) map[string]any { out := make(map[string]any) if mt, ok := asInt(req["max_tokens"]); ok && mt > 0 { diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index 59f83a6835..69a8aa3d51 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -405,6 +405,64 @@ func TestCleanToolSchema_NormalizesGeminiUnsupportedSchemaFields(t *testing.T) { require.NotContains(t, emptySchema, "type") } +func TestCleanToolSchema_ConvertsNestedIntegerExclusiveMinimum(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "counts": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "integer", + "exclusiveMinimum": float64(0), + }, + }, + "strict": map[string]any{ + "type": "integer", + "exclusiveMinimum": 0, + "minimum": 5, + }, + "weak": map[string]any{ + "type": "integer", + "exclusiveMinimum": 2, + "minimum": 1, + }, + }, + } + + cleaned := cleanToolSchema(schema).(map[string]any) + properties := cleaned["properties"].(map[string]any) + items := properties["counts"].(map[string]any)["items"].(map[string]any) + require.NotContains(t, items, "exclusiveMinimum") + require.Equal(t, float64(1), items["minimum"]) + + strict := properties["strict"].(map[string]any) + require.NotContains(t, strict, "exclusiveMinimum") + require.Equal(t, 5, strict["minimum"]) + + weak := properties["weak"].(map[string]any) + require.NotContains(t, weak, "exclusiveMinimum") + require.Equal(t, 3, weak["minimum"]) +} + +func TestCleanToolSchema_DropsAmbiguousExclusiveMinimumWithoutConversion(t *testing.T) { + for name, schema := range map[string]map[string]any{ + "number schema": { + "type": "number", + "exclusiveMinimum": 0, + }, + "fractional integer bound": { + "type": "integer", + "exclusiveMinimum": 0.5, + }, + } { + t.Run(name, func(t *testing.T) { + cleaned := cleanToolSchema(schema).(map[string]any) + require.NotContains(t, cleaned, "exclusiveMinimum") + require.NotContains(t, cleaned, "minimum") + }) + } +} + func TestConvertClaudeToolsToGeminiTools_PreservesWebSearchAlongsideFunctions(t *testing.T) { tools := []any{ map[string]any{